云上栖息地 - 2026年8月
type: channel
link: https://alwaysray.com/2026/08/
description: alwaysRay的云上栖息地。
items: Nginx 反向代理配置：从原理到负载均衡
items: 在 KB 级 RAM 的 MCU 上构建可靠系统
items: DMA 双缓冲与事件驱动：STM32L4 传感器数据采集的功耗优化

Nginx 反向代理配置：从原理到负载均衡
type: item
date: 2026-08-21T05:01:08+08:00
description: 反向代理的本质反向代理是个容易被误解的概念。正向代理替客户端访问服务端，反向代理替服务端接收客户端请求。一字之差，但方向完全相反。从部署看，反向代理更像一台"前台接待"，它不处理业务，只负责把请求转发给后端真正的服务，再把结果返回给客户端。引入反向代理解决的核心问题是：后端服务的暴露面。没有反向代理时，每个后端服务都得开放端口给外部，攻击面巨大。有了反向代理，外部只看到 80/443，后端服务全藏在内网。附加价值是负载均衡、缓存、SSL 卸载这些功能可以统一在代理层完成，后端服务不用各自实现一遍。设计代价是引入了一跳网络延迟和单点风险。如果代理挂了，所有后端服务都不可用——所以生产环境代理本身也要做高可用。这个成本必须接受，没有廉价方案。代理与负载均衡的差异很多人把 proxy_pass 和 upstream 混为一谈。它们不是一回事。proxy_pass 指向单个后端地址，只是转发，不涉及负载均衡。哪怕指向一个域名，如果 DNS 解析出多个 IP，Nginx 也只在启动时选一个。upstream 定义一组后端，Nginx 会在组内分配请求。这才是负载均衡。坑在于：很多人写 proxy_pass http://backend; 以为就负载均衡了，但如果没定义对应的 upstream backend {} 坯，Nginx 会把 backend 当域名做 DNS 解析。解析失败就起不来。基础代理配置最基础的反向代理配置，把 /api/ 的请求转发到后端 8080：# 实测于 Nginx 1.24.0, CentOS 7 server { listen 80; server_name api.example.com; location /api/ { proxy_pass http://127.0.0.1:8080/; # 透传真实客户端 IP，后端日志才有意义 proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; } } proxy_pass 末尾的斜杠是个经典坑。http://127.0.0.1:8080/ 带斜杠会把 /api/ 替换成 /，后端收到的路径是 /users；如果不带斜杠写成 http://127.0.0.1:8080，后端收到的是 /api/users。前后端路径不一致的时候，99% 是这里的问题。负载均衡配置引入 upstream 后，可以做多后端负载均衡：upstream backend { # 加权轮询（默认），weight 越高分配越多 server 192.168.1.10:8080 weight=3; server 192.168.1.11:8080 weight=1; # 备用服务器，只在主服务器都挂了才启用 server 192.168.1.12:8080 backup; # 容错：10 秒内失败 3 次就标记不可用，停 30 秒再试 # 这个参数写在 server 行内 } server { listen 80; location /api/ { proxy_pass http://backend/; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } Nginx 默认的负载均衡算法是加权轮询（Weighted Round Robin）。还有两种可选：- least_conn：转发给当前连接数最少的后端，适合请求处理时间差异大的场景- ip_hash：按客户端 IP 哈希固定后端，保证会话粘性ip_hash 看起来很美，但坑在于：客户端经过 NAT 出口，所有请求看起来都是同一个 IP，会集中打到一台后端。生产环境如果走 CDN，ip_hash 基本失效。实测记录：两个后端，weight 1:1，用 wrk -t4 -c100 -d30s 压测。- 理论预期：负载约 50:50 分配- 实际结果：49.7% : 50.3%，基本符合预期- 切到 ip_hash 后变成 87% : 13%——大多数 IP 都被哈希到同一个后端，验证了 NAT 场景下 ip_hash 失效偏差分析：ip_hash 的哈希空间只有 2 个桶（2 个后端），分布极度不均匀。后端数量越多，ip_hash 的均匀性越好；只有 2 个后端时几乎没意义。会话保持：用 cookie 而不是 ip_hash替代 ip_hash 的方案是 sticky cookie。N…

在 KB 级 RAM 的 MCU 上构建可靠系统
type: item
date: 2026-08-20T05:08:58+08:00
description: 小 RAM MCU 的现实约束STM32F103C8T6 是很多项目的入门选择，但它的 20KB RAM 和 64KB Flash 在实际开发中往往捉襟见肘。更现实的情况是 LoRa 远传节点——在 STM32L4 的 32KB RAM 里，LoRaWAN 协议栈要吃掉 8KB，传感器驱动 2KB，数据缓冲队列 4KB，OTA 预留 6KB，留给业务逻辑的只有 12KB。在这种资源约束下写代码，和在 256KB RAM 的 STM32F4 上完全是两种思路。不是功能能不能实现的问题，而是每分配一个缓冲区都要先算一遍还剩多少堆、栈还有多少空间。小 RAM 系统的核心痛点有三个：栈溢出、堆碎片化、缓冲区大小妥协。每一个都可能导致 HardFault，而且排查起来非常耗时——因为崩溃现场往往已经被破坏了。栈溢出：最隐蔽的杀手栈溢出在开发阶段往往不暴露，因为测试时中断嵌套少、函数调用浅。到了量产环境，中断密集时一层层栈帧叠上去，突然就溢出了。STM32 的栈是从高地址向低地址生长的。如果栈顶越过了 BSS 段的起始地址，就会破坏全局变量，表现出来的症状是某个全局变量莫名其妙被改了。解决思路是给栈加"水位标记"。在启动时把栈空间填满一个魔术值（比如 0xA5），运行一段时间后检查有多少魔术值没被覆盖，就知道栈的高水位线在哪里：// 实测于 STM32F103C8T6, GCC 12.2, -O2 extern unsigned int _estack; // 链接脚本定义的栈顶 extern unsigned int _Min_Stack_Size; #define STACK_CANARY 0xA5A5A5A5 void Stack_InitCanary(void) { uint32_t *stack_start = (uint32_t *)((uint32_t)&_estack - 4096); uint32_t *stack_end = (uint32_t *)&_estack; while (stack_start < stack_end - 16) { *stack_start++ = STACK_CANARY; } } uint32_t Stack_GetWatermark(void) { uint32_t *p = (uint32_t *)((uint32_t)&_estack - 4096); uint32_t used = 0; while (*p == STACK_CANARY && (uint32_t)p < (uint32_t)&_estack) { p++; used++; } return 4096 - used * 4; // 返回栈使用了多少字节 } 实测记录：在 LoRa 节点上运行 24 小时后检查，栈高水位线在 2.3KB 处。开发时以为只用 1KB 就够了，实际多出来一倍，主要是中断嵌套时 LoRaWAN 的 MAC 层函数调用链很深，每层都申请了局部数组。偏差分析：测试中断嵌套场景时发现 watermark 比单次中断高出 800 字节，怀疑是 SysTick 中断和 LoRa CAD 检测中断同时触发时的栈叠加。后续调高了栈大小到 4KB 才稳定。堆碎片化：用静态分配替代 malloc在小 RAM 系统上用 malloc/free 是灾难性的。堆碎片化会导致即使总剩余空间足够，也无法分配出一块连续的大块。我的策略是：根本不用堆。所有缓冲区改成静态分配，在编译阶段就固定大小和位置。// 不用 malloc，用静态数组 + 编译期大小 typedef struct { uint8_t buf[256]; uint16_t len; } Packet; // 编译期分配 8 个 Packet 池 static Packet packet_pool[8]; static uint8_t pool_used[8]; Packet* Packet_Alloc(void) { for (int i = 0; i < 8; i++) { if (!pool_used[i]) { pool_used[i] = 1; return &packet_pool[i]; } } return NULL; } void Packet_Free(Packet* p) { uint32_t idx = p - packet_pool; if (idx < 8) pool_used[idx] = 0; } 代价是池子大小必须编译期确定，不能动态扩展。但好处是不会碎片化、回收可预测、调试时能看到完整的占用情况。缓冲区大小：用时间换空间20KB RAM 不够让你同时缓存 N 路 CAN 报文和传感器数据。妥协的方式是用"时间换空间…

DMA 双缓冲与事件驱动：STM32L4 传感器数据采集的功耗优化
type: item
date: 2026-08-19T17:27:32+08:00
description: 传感器采集的功耗困境在电池供电的传感器节点中，MCU 大部分时间应该处于睡眠状态，只在数据就绪时被唤醒处理。但很多实现中，MCU 要么轮询 ADC，要么用中断频繁唤醒，导致实际睡眠时间远小于预期。DMA 双缓冲加事件驱动，是解决这个问题的经典思路。DMA 双缓冲的核心价值是：CPU 不参与数据搬运，且两块缓冲区交替使用——一块在被外设计数填充时，另一块正在被 CPU 处理。这避免了单缓冲方案中"等 DMA 传输完才能处理"的空窗期。STM32L4 系列的 Stop2 模式是个好东西。静态功耗 1.4μA，SRAM 内容保持，DMA 可以继续工作，唤醒延迟 35μs。这意味着传感器采集可以在 CPU 睡眠时进行，只在缓冲区满时才唤醒 CPU。双缓冲的工作逻辑双缓冲的关键是"乒乓切换"。DMA 配置两块缓冲区，用 Circular 模式让 DMA 在两块之间循环。每次半传输完成（HT）和传输完成（TC）中断都会触发 CPU 唤醒，处理对应的半块数据。设计代价是：内存占用翻倍，且需要同步机制防止 DMA 和 CPU 访问同一块缓冲区。简单说，HT 中断处理前半块，TC 中断处理后半块，两块交替使用，天然避免了竞争。缓冲区布局： [Buffer A (512B)] [Buffer B (512B)] ↑ ↑ HT 中断处理 TC 中断处理 (DMA 正在填 B) (DMA 正在填 A) 实操配置以下配置基于 STM32L432KC，用 CubeMX 生成 HAL 框架代码，手动补充双缓冲逻辑：// 实测于 STM32L432KC, STM32Cube HAL 1.17.0 // ADC1 通道 5（PB1），DMA 双缓冲，12 位分辨率 #define BUF_SIZE 512 static uint16_t adc_buf[2][BUF_SIZE]; // 双缓冲 void ADC_DMA_Init(void) { // ADC 配置：72MHz/6 = 12MHz，采样时间 47.5 周期 // 单次转换约 4μs，512 次约 2ms hadc1.Init.ContinuousConvMode = ENABLE; hadc1.Init.DMAContinuousRequests = ENABLE; // DMA 配置：Circular 模式，半字传输 hdma_adc1.Init.Mode = DMA_NORMAL; // 用 Normal + 手动重启实现双缓冲切换 // 注意：HAL 库的 DMA_CIRCULAR 模式下 HT/TC 中断稳定 // 但实测发现双缓冲切换用 DMA_NORMAL + 手动重启更可靠 hdma_adc1.Init.Mode = DMA_CIRCULAR; HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, BUF_SIZE * 2); // 注册 HT 和 TC 回调 HAL_ADC_RegisterCallback(&hadc1, HAL_ADC_CONV_HALF_CB_ID, ADC_HT_Callback); HAL_ADC_RegisterCallback(&hadc1, HAL_ADC_CONV_CPLT_CB_ID, ADC_TC_Callback); } void ADC_HT_Callback(ADC_HandleTypeDef* hadc) { // DMA 正在填 Buffer B，处理 Buffer A ProcessData(adc_buf[0], BUF_SIZE); } void ADC_TC_Callback(ADC_HandleTypeDef* hadc) { // DMA 正在填 Buffer A，处理 Buffer B ProcessData(adc_buf[1], BUF_SIZE); } 进入 Stop2 模式前的配置很关键。必须先挂起所有外设时钟，只保留 ADC 和 DMA 时钟，否则 Stop2 进不去：void EnterStop2(void) { // 关闭不需要的外设时钟 __HAL_RCC_GPIOA_CLK_SLEEP_DISABLE(); // 保留 ADC 和 DMA 时钟 __HAL_RCC_ADC_CLK_SLEEP_ENABLE(); __HAL_RCC_DMA1_CLK_SLEEP_ENABLE(); // 进入 Stop2 HAL_PWREx_ControlLowPowerRunMode(PWR_LOWPOWERRUNMODE_ENABLE); HAL_PWREx_EnterSTOP2Mode(PWR_STOPENTRY_WFI); // 唤醒后恢复时钟 SystemClock_Co…