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 这个坑。

做工业项目时,STM32 需要和 PLC 通信。Modbus RTU 是最常见的工业协议,libmodbus 是最流行的开源实现。但 libmodbus 是为 Linux 设计的,移植到 STM32 需要替换底层硬件操作。

为什么选 libmodbus

  • 成熟稳定,社区活跃
  • 支持 Modbus RTU/TCP
  • API 设计清晰,易于使用
  • 有丰富的示例代码

坑在于:libmodbus 依赖 POSIX API(openreadwrite),STM32 裸机环境下没有这些。

移植思路

libmodbus 的架构分两层:

  • 上层:Modbus 协议处理(CRC 计算、报文组装/解析)
  • 底层:串口收发、定时器、延时

移植的关键是保留上层,替换底层。

第一步:简化源码

# 下载 libmodbus
git clone https://github.com/stephane/libmodbus.git

# 只保留核心文件
src/
├── modbus.c          # Modbus 协议核心
├── modbus.h          # 公共头文件
├── modbus-private.h  # 私有头文件
├── modbus-rtu.c      # RTU 模式实现
├── modbus-rtu.h      # RTU 模式头文件
└── config.h          # 配置文件

删除 TCP 相关文件(modbus-tcp.cmodbus-tcp.h),只保留 RTU 模式。

第二步:配置 config.h

// config.h
#define HAVE_STRLCPY 1
#define HAVE_STRERROR 1
#define HAVE_ARPA_INET_H 0  // STM32 没有
#define HAVE_NETINET_IN_H 0
#define HAVE_SYS_SOCKET_H 0
#define HAVE_WINSOCK2_H 0

坑在于:很多宏默认开启,STM32 环境下要手动关闭。

第三步:替换串口收发

libmodbus 的底层操作在 modbus-rtu.c_step 函数里:

// 原始代码(Linux)
static ssize_t _step(modbus_t *ctx, uint8_t *msg, int msg_length)
{
    return write(ctx->s, msg, msg_length);
}

// 替换为 STM32 HAL
static ssize_t _step(modbus_t *ctx, uint8_t *msg, int msg_length)
{
    modbus_rtu_t *ctx_rtu = ctx->backend_data;
    HAL_StatusTypeDef status;

    // 发送
    status = HAL_UART_Transmit(&huart1, msg, msg_length, 1000);
    if (status != HAL_OK) {
        return -1;
    }

    // 接收
    status = HAL_UART_Receive(&huart1, msg, msg_length, 1000);
    if (status != HAL_OK) {
        return -1;
    }

    return msg_length;
}

第四步:替换延时函数

// 原始代码(Linux)
static void usleep_ms(int ms)
{
    usleep(ms * 1000);
}

// 替换为 STM32 HAL
static void usleep_ms(int ms)
{
    HAL_Delay(ms);
}

第五步:替换互斥锁(可选)

如果是单线程环境(裸机或 FreeRTOS 单任务),可以简化互斥锁:

// 简化为无操作
#define LOCK(ctx)
#define UNLOCK(ctx)

第六步:初始化和使用

#include "modbus.h"

int main(void)
{
    // 初始化硬件
    HAL_Init();
    SystemClock_Config();
    MX_USART1_UART_Init();

    // 创建 Modbus RTU 上下文
    modbus_t *ctx = modbus_new_rtu("/dev/ttyS0", 9600, 'N', 8, 1);
    if (ctx == NULL) {
        printf("无法创建 Modbus 上下文\n");
        return -1;
    }

    // 设置从站地址
    modbus_set_slave(ctx, 1);

    // 连接(RTU 模式不需要,但要调用)
    modbus_connect(ctx);

    // 读取保持寄存器(功能码 0x03)
    uint16_t tab_reg[10];
    int rc = modbus_read_registers(ctx, 0, 10, tab_reg);
    if (rc == 10) {
        printf("读取成功:");
        for (int i = 0; i < 10; i++) {
            printf("%d ", tab_reg[i]);
        }
        printf("\n");
    } else {
        printf("读取失败,错误码:%d\n", rc);
    }

    // 写入单个寄存器(功能码 0x06)
    rc = modbus_write_register(ctx, 0, 12345);
    if (rc == 1) {
        printf("写入成功\n");
    }

    // 关闭连接
    modbus_close(ctx);
    modbus_free(ctx);

    return 0;
}

常见问题

1. CRC 校验失败

// 检查波特率配置
modbus_t *ctx = modbus_new_rtu("/dev/ttyS0", 9600, 'N', 8, 1);
//                                    波特率    校验 数据位 停止位

坑在于:PLC 和 STM32 的波特率、校验位、数据位、停止位必须一致。

2. 超时问题

// 设置超时时间
struct timeval timeout;
timeout.tv_sec = 0;
timeout.tv_usec = 100000;  // 100ms
modbus_set_response_timeout(ctx, &timeout);

3. 从站地址不对

// 设置从站地址(要和 PLC 配置一致)
modbus_set_slave(ctx, 1);  // 从站地址 1

踩坑总结

  1. libmodbus 依赖 POSIX API,STM32 需要替换底层操作
  2. 核心替换:串口收发、延时函数、互斥锁
  3. 保留上层协议处理(CRC、报文组装/解析)
  4. 波特率、校验位、数据位、停止位必须和 PLC 一致
  5. 从站地址要和 PLC 配置一致

从"libmodbus 不能用"到"STM32 和 PLC 通信成功",只需要替换底层操作。坑在于:很多人被 POSIX API 吓退了,其实替换量不大。

部署一个 Python + Redis + Nginx 的服务,容器启动顺序搞错了。Redis 还没 ready,Python 服务就报 Connection refused。排查了半天,发现是 depends_on 只保证容器启动顺序,不保证服务就绪。

问题现象

# docker-compose.yml
services:
  web:
    build: .
    depends_on:
      - redis
    ports:
      - "8000:8000"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
docker-compose up -d

Python 服务启动后报错:

redis.exceptions.ConnectionError: Error 111 connecting to localhost:6379. Connection refused.

坑在于:depends_on 只保证 Redis 容器先启动,不保证 Redis 服务已经就绪。Redis 容器启动了,但 Redis 服务可能还在初始化。

解决方案

方案一:healthcheck + condition(推荐)

services:
  web:
    build: .
    depends_on:
      redis:
        condition: service_healthy
    ports:
      - "8000:8000"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

healthcheck 定义健康检查命令,condition: service_healthy 等待 Redis 健康后再启动 web。

方案二:启动脚本等待

# app.py
import redis
import time

def wait_for_redis(host, port, max_retries=30):
    """等待 Redis 就绪"""
    for i in range(max_retries):
        try:
            r = redis.Redis(host=host, port=port)
            r.ping()
            print("Redis 已就绪")
            return r
        except redis.ConnectionError:
            print(f"等待 Redis... ({i+1}/{max_retries})")
            time.sleep(1)
    raise Exception("Redis 连接超时")

r = wait_for_redis("redis", 6379)

坑在于:这个方案需要在应用代码里加等待逻辑,不够优雅。

方案三:wait-for-it 脚本

services:
  web:
    build: .
    depends_on:
      - redis
    command: >
      sh -c "wait-for-it -t 30 redis:6379 -- python app.py"
    ports:
      - "8000:8000"
# Dockerfile
FROM python:3.11-slim

# 安装 wait-for-it
RUN apt-get update && apt-get install -y wait-for-it && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .

这个方案需要在镜像里安装 wait-for-it,增加镜像体积。

完整配置示例

Python + Redis + Nginx

services:
  web:
    build: .
    depends_on:
      redis:
        condition: service_healthy
    environment:
      - REDIS_HOST=redis
      - REDIS_PORT=6379
    ports:
      - "8000:8000"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    volumes:
      - redis_data:/data

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      web:
        condition: service_started

volumes:
  redis_data:

健康检查配置详解

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
  interval: 10s      # 检查间隔
  timeout: 5s        # 超时时间
  retries: 3         # 重试次数
  start_period: 30s  # 启动等待时间

start_period 给容器启动的时间,这段时间内的失败不算在 retries 里。

网络配置

services:
  web:
    build: .
    networks:
      - app-network

  redis:
    image: redis:7-alpine
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

同一个网络里的容器可以用服务名互相访问,比如 redis:6379

常用命令

# 启动所有服务
docker-compose up -d

# 查看服务状态
docker-compose ps

# 查看日志
docker-compose logs -f web

# 重建并启动
docker-compose up -d --build

# 停止并清理
docker-compose down

# 停止并清理数据卷
docker-compose down -v

踩坑总结

  1. depends_on 只保证容器启动顺序,不保证服务就绪
  2. healthcheck + condition: service_healthy 等待服务健康
  3. start_period 给容器启动的时间,避免误判
  4. 同一个网络里的容器可以用服务名互相访问
  5. 生产环境用 docker-compose up -d --build 重建镜像

从"容器启动了但服务没就绪"到"服务健康后再启动依赖",只需要配置 healthcheck + condition。坑在于:很多人只用 depends_on,不知道还有 condition 这个选项。