从沙子到车辙(3.5):存储层次

3.5 存储层次

📚 本文内容摘自本人的开源书《从沙子到车辙 - 一个工程师的理解》

🔗 在线阅读/下载:from-sand-to-ruts

bash 复制代码
git clone https://github.com/Lularible/from-sand-to-ruts

⭐ 如果对您有帮助,欢迎 Star 支持,也欢迎通过 GitHub Issues 交流讨论。

图书管理员的聪明办法

你是一个图书管理员。图书馆有几百万本书,都在地下仓库里。读者来借书,你要去地下仓库找------一趟来回 10 分钟。读者排队,每个人都要等 10 分钟。

你受不了了。

你在借阅台后面放了一个小书架------最多 50 本书。每次读者还书,你把最常用的书放在小书架上。有人来借:如果书在小书架上,3 秒拿出来。如果不在,再去地下仓库取------顺便把取回来的书也放在小书架上,挤掉最不常用的那本。

这就是 Cache(缓存)。

你不可能把所有书都放在手边(太贵了)。但你可以把最常用的放在手边。 这个策略之所以有效,靠两个规律:

  • 时间局部性:你刚刚翻过的书,大概率还会再翻。
  • 空间局部性:你正在看第 5 章,大概率马上需要第 6 章。

所有计算机的存储层次------从寄存器到 SSD------遵循的都是同样的逻辑。

存储金字塔

复制代码
     +----------+  最快/最贵/最小
     | 寄存器   |  ~1ns, ~1KB,  在 CPU 核心里
     +----------+
     | L1 Cache|  ~1-2ns, ~32KB,  在核心里
     +----------+
     | L2 Cache|  ~3-10ns, ~256KB, 在同一个 die 上
     +----------+
     | SRAM    |  ~5-20ns, ~64-256KB, 片上
     +----------+
     | DRAM    |  ~50-100ns, ~几百 MB, 外部芯片
     +----------+
     | Flash   |  ~50-100ns (读), ~ms (擦写), 片上或外部
     +----------+
     | SSD/HDD |  ~μs-ms, ~GB-TB, 外部存储
     +----------+
     最慢/最便宜/最大

注:这是通用计算机的存储层次。在车规 MCU(如 Cortex-M4/M7)中,通常只有 L1 Cache 或没有 Cache,片上 SRAM 直接挂在系统总线上,延迟为确定的单周期或几周期。

每一层之间,延迟差一个数量级。寄存器 ~1ns,L1 Cache ~1-2ns,SRAM ~5-20ns,DRAM ~50-100ns。从寄存器到 DRAM,差两个数量级。从寄存器到 SSD,差六个数量级。

如果没有两个局部性------如果你的内存访问是完全随机的------Cache 对你毫无用处。 每次访问都 miss,你只是在为存不下的数据付出额外的查找开销。

而幸运的是,几乎所有程序的访存模式都遵循这两个局部性。指令顺序执行,数据集中分布------这是图灵机和冯·诺依曼架构带给我们的礼物。

Cache 怎么工作:Tag、Index、Offset

以最简单的直接映射 Cache 为例。

Cache 被分成若干 Cache Line(比如每个 32 字节)。内存地址被切成三个字段:

复制代码
| Tag (高位) | Index (中间) | Offset (低位) |
  • Index:决定数据映射到哪个 Cache Line。同一个 Index 的不同地址共享同一个 Line 位置。
  • Tag:存到该 Cache Line 中,用来判断当前缓存的数据是不是你要的那个地址。
  • Offset:在 Cache Line 内定位到具体字节。

CPU 访问一个地址时:

  1. 用 Index 找到对应的 Cache Line。
  2. 比较 Tag------匹配就是 Cache Hit,数据直接返回,1 个时钟。
  3. 不匹配------Cache Miss。硬件自动从下一级存储(L2 或主存)把整个 Cache Line 加载进来,替换掉旧的。延迟 10-100 个时钟。

亲手拆一个地址

假设一个直接映射 Cache:

  • Cache 容量 16KB
  • Cache Line = 32 字节
  • 所以有 16KB / 32B = 512 个 Cache Line(Index 占 9 位)
  • Offset 占 5 位(log2 32)
  • 32 位地址中,Tag 占 32 - 9 - 5 = 18 位

访问地址 0x0000A010

复制代码
0x0000A010 = 0000 0000 0000 0000 0000 0000 1010 0000 0001 0000 (二进制)
              |___ Tag (18) ____| |_ Index (9) _| |_Off(5)_|

Tag   = 0x00000
Index = 0b101000000 = 320
Offset = 0x10 = 16

Cache 控制器做的事:查看 Set 320 中 Tag 字段是否等于 0x00000,Valid 位是否为 1。如果匹配------Cache Hit。如果不匹配------Cache Miss,从主存加载地址 0x0000A010 所在的整个 32 字节块(从 0x0000A0000x0000A01F)到 Set 320,把 Tag 写成 0x00000,Valid 置 1。

在往下读代码之前,先在脑子里把这个地址走一遍:Index=320指向第320号Cache Line。硬件检查这一行的Tag是否等于0x00000。如果是------命中(Hit),直接读出offset=16处的数据。如果不是------缺失(Miss),硬件自动从下一级存储(L2或主存)加载整个32字节的块,替换这一行,更新Tag。下面的C代码就是在模拟这个"检查→命中/缺失→替换"的循环。

用 C 实现一个直接映射 Cache 模拟器

c 复制代码
#include <stdio.h>
#include <stdint.h>
#include <string.h>

#define CACHE_LINES 512
#define LINE_SIZE   32

typedef struct {
    uint8_t  valid;
    uint32_t tag;
    uint8_t  data[LINE_SIZE];
} CacheLine;

CacheLine cache[CACHE_LINES];
uint32_t hits = 0, misses = 0;

uint8_t cache_access(uint32_t addr, uint8_t write, uint8_t data) {
    uint32_t offset = addr & 0x1F;           // 低 5 位
    uint32_t index  = (addr >> 5) & 0x1FF;   // 中 9 位
    uint32_t tag    = addr >> 14;             // 高 18 位

    CacheLine *line = &cache[index];

    // Cache Hit
    if (line->valid && line->tag == tag) {
        hits++;
        if (write) line->data[offset] = data;
        return line->data[offset];
    }

    // Cache Miss
    misses++;
    // 在真实硬件中,这里会从主存加载整个 Cache Line
    // 模拟中我们直接置 Valid 和 Tag
    line->valid = 1;
    line->tag   = tag;
    if (write) line->data[offset] = data;
    return line->data[offset];
}

void run_trace(uint32_t addrs[], int count) {
    hits = misses = 0;
    memset(cache, 0, sizeof(cache));
    for (int i = 0; i < count; i++)
        cache_access(addrs[i], 0, 0);
    printf("Hit rate: %.2f%% (%u hits, %u misses)\n",
           100.0 * hits / count, hits, misses);
}

测试:顺序访问 0x1000, 0x1004, 0x1008, 0x2000, 0x100C

复制代码
0x1000 → Index=128, Tag=0 → Miss (cold)
0x1004 → Index=128, Tag=0 → Hit  (同 Line, 0x1000-0x101F)
0x1008 → Index=128, Tag=0 → Hit
0x2000 → Index=256, Tag=0x0 → Miss(与0x1000在同一组,Tag不同------冲突缺失)
0x100C → Index=128, Tag=0 → Hit  (还在 Line 128 中)

命中率 = 3/5 = 60%。注意 0x2000 映射到 Index=0 而不是 Index=128------因为它和 0x1000 的 Index 不同(0x2000 >> 5 = 0x100, 0x1000 >> 5 = 0x80)。它们不会冲突。但如果后续访问 0x100A000(Index 也是 128,Tag=0x80)------就会驱逐当前 0x1000 的 Cache Line。

这就是直接映射的"冲突缺失"(Conflict Miss):两个不同的地址映射到了同一个 Cache Line。

组相连与 LRU 替换

直接映射的缺点很明显:如果两个频繁访问的地址恰好撞到同一个 Index,就会反复把对方踢出去------抖动(thrashing)。

组相连 Cache(Set-Associative Cache)缓解了这个问题:每个 Index 对应 N 个 Cache Line(N 路),地址可以在 N 路中任选一个存放。N=2 叫 2 路组相连,N=16 叫 16 路组相连。N 越大,冲突越少,但硬件越复杂(需要并行比较 N 个 Tag)。

当 N 路都满了,需要踢掉一个时------用哪个策略?

**LRU(Least Recently Used)**是最经典的替换算法:踢掉最久没有被访问的那一路。用 C 实现一个 4 路组相连 LRU Cache:

复制代码
// LRU 替换策略(概念伪代码):
function access_cache(set_index, tag):
    for way in 0..WAYS-1:
        if cache[set][way].valid && cache[set][way].tag == tag:
            // Cache Hit
            update_lru(set_index, way)
            hits++
            return cache[set][way].data

    // Cache Miss:先找空路
    for way in 0..WAYS-1:
        if !cache[set][way].valid:
            fill_line(set_index, way, tag)
            return

    // 无空路:踢掉 LRU 最久未被访问的 way
    victim = find_lru_way(set_index)
    if cache[set][victim].dirty:
        write_back(victim)
    cache[set][victim].tag = tag
    cache[set][victim].valid = 1
    update_lru(set_index, victim)
    misses++

核心思路:每个 Set 内的每一路维护一个"年龄"计数。每次访问命中时,被命中的路年龄重置为最大(最新),其他路递减。当需要替换时,选择年龄最小的路------它最久没被访问过。

实际上,find_lru_wayupdate_lru 不需要存储完整时间戳。以 4 路组相连为例,只需 6 个 bit 编码 4! = 24 种相对访问顺序------用一个 LRU 状态机即可,不必每路配一个 32 位计数器。

这个模拟器让你看到:LRU 的实现成本随路数 N 线性增长。 在每个 Set 内,你需要存储 N 个 LRU 时间戳并做 N 路比较。真实硬件中,4 路组相连用 6 个 bit 的 LRU 状态机就够了(不是完整时间戳,而是相对排序编码),但 16 路以上通常改用伪 LRU(PLRU)或随机替换------因为真 LRU 的硬件开销已经不值得那微小的命中率提升。

写策略:Write-Through vs Write-Back

当 CPU 写数据时,Cache 面临一个选择:

Write-Through :同时写 Cache 和下一级存储(主存)。优点:简单,Cache 和主存永远一致------不需要 dirty bit。缺点:每次 Store 都要等主存写入确认------延迟高、功耗高。通常配合 Write Buffer 使用------CPU 把写数据放入一个 FIFO,Write Buffer 在后台慢慢写主存,CPU 不用等。但如果 Write Buffer 满了,CPU 还是得 stall。

Write-Back:只写 Cache,标记该 Line 为 "dirty"(脏)。当该 Line 被替换出去时,再把整个 Line 写回主存。优点:Store 延迟低(只写 Cache),总线流量少。缺点:需要 dirty bit,Cache 控制器逻辑更复杂;掉电时 Cache 中的脏数据丢失。

在写操作 miss 时还有一个选择:

Write-Allocate:先加载整个 Line 到 Cache,再在 Cache 中写该字节。利用了空间局部性------你可能马上还会写这个 Line 的相邻字节。

No-Write-Allocate:绕过 Cache 直接写主存。适合流式写入(大数据块只写一次,不会重复使用)。

典型组合:

  • Write-Back + Write-Allocate:最常见,比如 Cortex-A 系列的 L1/L2 Cache。
  • Write-Through + No-Write-Allocate:在实时系统中多见------减少了Cache一致性维护的复杂度,保证了确定性。

多核一致性问题:MESI 协议

如果你有两个 CPU 核------每个有自己的 L1 Cache------它们各自缓存了同一块主存地址 0x2000 的副本。CPU0 修改了它------CPU1 看到的还是旧值。

这就是 Cache Coherence(缓存一致性) 问题。

想象两个图书管理员各自有一个小书架(L1 Cache),共享同一个地下仓库(主存)。当管理员A在自己书架上修改了一本书------管理员B书架上的那本同样的书就变成了"旧版"。他们需要一种方式互相通知。MESI协议就是这套通知规则:M(Modified,我的书比仓库新,别人不能有)、E(Exclusive,我的书和仓库一样新,别人不能有)、S(Shared,我的书和仓库一样新,别人也可以有)、I(Invalid,我的书过时了,要读的话从仓库重新拿)。

MESI 协议是最经典的 snooping-based 一致性协议。每个 Cache Line 处于四种状态之一:

  • Modified(M):该 Line 只在这个 Cache 中有副本,且已被修改(dirty)。主存中的副本是过期的。
  • Exclusive(E):该 Line 只在这个 Cache 中有副本,且与主存一致(clean)。
  • Shared(S):该 Line 在多个 Cache 中有副本,所有副本与主存一致。
  • Invalid(I):该 Line 无效。

状态转换的核心规则:

复制代码
CPU 读 → 如果 I:发 BusRd,转到 S(或 E,如果没有其它共享者)
CPU 写 → 如果 S/E:发 BusUpgr(invalidate 其它副本),转到 M
Snoop BusRd → 如果 M:写回主存,转到 S
Snoop BusRdX(别的 CPU 要写)→ 转到 I

MESI 是一种监听(snooping)协议 ------每个 Cache 控制器都在总线上监听其他 Cache 的读写操作,根据地址判断是否需要更新自己的状态。这在总线系统中工作良好,但在大型多核(16+ 核)中总线带宽成为瓶颈------现代 CPU 转向目录协议,用一个中央目录记录每个 Cache Line 被哪些核共享。

对于汽车 MCU 来说,多核一致性通常简单得多。Cortex-R5 的双核锁步模式中,两个核运行相同代码------不存在一致性问题。即使是非锁步的多核 MCU(比如 Renesas RH850、Infineon Aurix),核间通信用的是共享 SRAM 而不是 hardware-coherent Cache------程序员用 memory barrier 指令(DSB/DMB/ISB in ARM)显式管理一致性。

车规 MCU 为什么不爱 Cache

许多车规 Cortex-M4 MCU 没有 Cache (或只有简单的指令 Cache)。Cortex-M7 通常同时具有 I-Cache 和 D-Cache------这是它的架构特性决定的(六级流水线需要低延迟的指令和数据供应)。但在功能安全关键路径上,工程师往往主动禁用 D-Cache,以保证 WCET 分析的可预测性。它们用紧耦合 SRAM(System RAM)------片上 SRAM 的访问延迟是确定的单周期(或两三个等待周期)。

不用的原因就一个:WCET 无法分析。

  • Cache miss 发生在哪一次循环迭代?不知道。取决于历史访问模式。
  • Flash 的等待周期在当前 VCC 和温度下是多少?不确定。
  • ISR 可能因为一次 cache miss 时间加倍------你敢让 ABS 的 ISR 有这种不确定性吗?

车规 MCU 宁愿把这些硅面积拿来做更多的片上 SRAM,也不做 Cache。面积换确定性。

TCM:软件显式管理的"确定性 Cache"

Cortex-R5 的 TCM 设计更是把这个思路推到极致:不是"盲目的透明 Cache",是软件显式管理的紧耦合存储

让我给你一个具体的画面。在 Cortex-R5 的 MPU 配置中,我把 ISR 代码锁到了 TCM 的 A 区域(ATCM):

复制代码
TCM A (ATCM):0x00000000-0x00003FFF  --- ISR 代码 + 关键数据
TCM B (BTCM):0x00004000-0x00007FFF  --- 栈空间
TCM R (TCMR):配置寄存器

TCM 的访问延迟是确定的一或两个周期------跟 Cache Hit 一样快,但没有不确定性。每次 CPU 访问 TCM 地址空间,数据一定在那个周期内返回。没有 tag 比较,没有 miss,没有替换,没有状态机。

代价是什么呢?TCM 的总容量很小(Cortex-R5 上典型 32-64KB)------因为它是用 SRAM 做的,和 Cache 争夺同一块硅面积。而且内容必须由程序员显式管理------你在 linker script 中声明哪些 section 放在 TCM,在运行时用 DMA 把关键数据搬进 TCM,用完后搬出来。没有硬件自动化。

TCM 和 Cache 的根本区别:TCM 是"我知道什么快",Cache 是"我相信什么快"。 在安全关键系统中,"我相信"是不够的------你需要"我知道"。

SRAM 单元的物理脆弱性

SRAM 单元由 6 个晶体管组成------4 个构成两个交叉耦合的反相器(存储 0 或 1),2 个是访问晶体管(连接字线和位线)。

复制代码
         WL (字线)
          │
    M5 ───┼─── M6
          │
    ┌──M1─┴──┐
    │  交叉  │
    │  耦合  │
    └──M2─┬──┘
          │
         BL   BL'
        (位线)

这个结构的稳定性取决于 6 个晶体管的 Vt(阈值电压)匹配。在制造过程中,由于随机掺杂波动(RDF, Random Dopant Fluctuation),M1 和 M2 的 Vt 可能有微小差异。在小尺寸工艺(28nm 以下),RDF 的影响更加显著------一个晶体管中掺杂原子只有几十个,多一个或少一个都可能导致 Vt 漂移几十 mV。

如果 M1 的 Vt 漂高(变得更难导通)而 M2 的 Vt 正常------那么在读操作时,M5 导通、BL 被拉到低电平的过程中,M1 的驱动力不足。交叉耦合反相器的正反馈可能被破坏------存储的"1"翻转成了"0"。

这叫做读破坏(Read Disturb) 。它是 SRAM 在小尺寸下最常见的一个物理失效机制------不是电路设计错误,是制造变异使单个 bit cell 的噪声裕度降到了一个标准偏差以下

MBIST(Memory Built-In Self Test)的作用就是在晶圆测试和封装后测试中,遍历所有 bit cell 的各种访问序列------连续读、连续写、写后立即读、相邻行扰动------来暴露那些噪声裕度不够的弱 bit。车规 SRAM 的 MBIST 覆盖率要求接近 100%,因为------你能容忍你的 ECU 在某次过弯时因为一个弱 SRAM bit 导致堆栈数据翻转吗?

Flash 的擦写寿命:一个收敛的故事

一片标准的嵌入式 NOR Flash 的擦写寿命是 10 万次。

假设你的 DTC(诊断故障码)log 每 10 秒记录一次诊断数据。每次写入一个 4 字节的 DTC 条目。

如果你直接把 DTC log 放在 Flash 的一个扇区里------每次写都擦除整扇再写回------10 万次 ÷ (24小时 × 3600秒 / 10秒)≈ 11.6 天。你的 Flash 在不到两周内就报废了。

这就是为什么所有 Flash 存储系统必须要磨损均衡(Wear Leveling)。你不在同一个物理扇区反复擦写------而是在一片更大的 Flash 区域内(比如 64KB)用软件循环使用扇区。每次写到一个新位置,同时维护一个映射表------告诉你"逻辑地址 0x0000"对应的当前物理扇区是哪一个。

更进一步------你用 SRAM 做缓冲。DTC 数据先积累在 SRAM 里(积累到 512 字节),然后一次性编程到 Flash。10 秒的写入频率变成了(512/4)× 10 = 1280 秒 ≈ 21 分钟。同样 10 万次擦写寿命,现在能撑约 38 年------超过车的设计寿命。

三层协作:为什么没有一层能单独解决问题

这个设计里藏着三层协作。每一层都单独不够用,组合起来才能满足整车的全生命周期要求:

复制代码
SRAM (速度) → 快速缓冲,但掉电丢失
Flash (持久) → 掉电不丢,但擦写慢、寿命有限
磨损均衡 (寿命) → 分摊擦写,但需要额外计算
三者协作 → 完整的非易失性存储系统

三层分别解决速度、持久性和寿命------SRAM提供快速缓冲、Flash提供持久化存储、磨损均衡算法平均化擦写。硬件层面,SRAM和Flash是两种物理介质;软件层面,磨损均衡算法是一段代码。三层跨越了硬件和软件的边界,共同构成一个可靠的存储子系统。

你的 Embedded C 和存储层次

中断响应:Flash 里的 ISR

你写了一个 ISR,读 CAN 报文并存储到环形缓冲。ISR 被放在 Flash 里。Flash 读取延迟------在 112MHz 下------可能是 3-5 个等待周期。没有指令 Cache,每次 fetch 都 miss。

优化:把 ISR 重映射到 SRAM(通过 linker script 的 section 放置),或启用指令 Cache + 把关键代码锁到 Cache Line。

循环:局部性的教科书

c 复制代码
for (int i = 0; i < 1024; i++) {
    dst[i] = src[i] * gain;
}

完美的空间局部性(顺序访问 srcdst)和时间局部性(循环体指令重复执行)。有 data cache 时,命中率接近 100%。没有 Cache 时片上 SRAM 也是单周期的------只是功耗高一点(每次迭代都在读取 SRAM)。

Flash 模拟 EEPROM:多层的协作

你的 MCU 没有独立 EEPROM,但标定参数需要掉电不丢失。你用片上 Flash 来模拟:

  • Flash 擦除是按扇区的(几 KB 到几十 KB),擦写寿命 ~10 万次。
  • 更新参数前:把该扇区的其他有效数据备份到 SRAM → 擦除整个扇区 → 把 SRAM 备份 + 新参数写回 Flash。

这里面暗含了存储层次的协作:Flash(非易失性、慢擦写)+ SRAM(易失性、快读写)= 一个可靠的非易失性存储系统。 你利用了两个层次各自的优势------Flash 的持久性和 SRAM 的灵活读写------来构建一个单层存储提供不了的能力。

有限资源的最优解

存储层次回答的问题是:如何用有限资源做到"接近最快"的性能?

你不可能把所有数据都放在寄存器里------寄存器只有几十个。不能都放在 Cache 里------Cache 只有几十 KB。不能都放在 SRAM 里------SRAM 也只有几百 KB。但你可以把这些层次组合起来,让程序员在写 data[x] = y 的时候,感觉不到多层存储的存在。

这是"透明的优化"------在用户无感知的情况下,把有限资源用到极致。

这个哲学贯穿了整个计算机系统设计:

  • 你没有无限的存储,但你有多层缓存------有限资源最优解。
  • 你没有完美的网络,但你有时间同步和确定性调度------有限资源最优解。
  • 你没有无限的人力做充分测试,但你用 MISRA C、ISO 26262 流程、形式化方法把风险压到 ASIL 级别------还是有限资源最优解。

你用 128KB SRAM 在 ECU 上跑通一个 CAN 诊断栈------是在践行这个信条。你用硬件触发器和组合逻辑在纳秒精度下捕捉时间戳------是在践行这个信条。你设计一套基于 Flash 模拟 EEPROM 的存储架构省掉一颗外部芯片------还是在践行这个信条。

存储层次,是"有限资源最优解"最优雅的体现。


本篇小结

今天我们做了一件事:理解了存储层次------"快"和"大"不可兼得,于是我们用多级存储来骗过这个限制。

关键结论:

  1. 存储层次是逐级妥协:寄存器(亚纳秒/几KB)→ Cache(几纳秒/几十KB)→ SRAM(十几纳秒/几百KB)→ DRAM(几十纳秒/几百MB)→ Flash(微秒级/GB)------每层速度差一个数量级。
  2. Cache靠局部性工作:时间局部性(刚用过的可能再用)和空间局部性(附近的可能被用)------命中率通常在90%以上。
  3. 硬实时系统需要确定性:Cache miss的时间不可预测,所以车规MCU倾向用TCM------把关键代码和数据锁在固定位置的SRAM里,延迟完全确定。

下一部分,计算的连接------从片内总线到车载网络,芯片与芯片之间怎么对话。

【下集预告】

片上计算能力拉满了,存储层次也搭好了。但一个 ECU 不是孤岛------它要和传感器对话,要和执行器对话,要和其他 ECU 对话。

芯片与芯片之间怎么通信?I2C、SPI、UART、CAN、FlexRay、Ethernet------这些总线到底在物理层和数据链路层做了什么?谁来决定"轮到谁说话了"?

下一部分,我们从片内走向整车------总线、协议、网络。Part 4 正式启程。

相关推荐
lularible6 小时前
从沙子到车辙(3.4):流水线——指令级并行的艺术
开源·嵌入式·汽车电子
2601_955781987 小时前
整合Kimi 大模型 OpenClaw 自动化能力再度升级
开源·github·kimi·open claw安装·open claw部署
我叫不睡觉8 小时前
知识内耗时代终结:用 FastGPT 构建企业级 AI 知识大脑的完整实践
人工智能·开源
lularible8 小时前
从沙子到车辙(3.1):组合逻辑——没有记忆的计算
开源·嵌入式·汽车电子
DogDaoDao9 小时前
【AI Agent 深度解析】OpenHuman 开源项目全面分析 — 打造你的个人 AI 超级智能助手
人工智能·深度学习·开源·大模型·ai agent·智能体·openhuman
前端白袍9 小时前
AI+:OpenClaw:开源 AI Agent 框架的定位与技术分析
人工智能·开源·openclaw
星栈9 小时前
Rust WASM 文件上传全链路:从浏览器到 S3,一个字节都不能少
前端·前端框架·开源
上海知从科技10 小时前
SENT传输协议:汽车传感器数字化通信的最优解决方案
科技·安全·汽车·软件工程·汽车电子