从沙子到车辙(4.2):从片内到片间——SPI、I2C

4.2 从片内到片间:SPI、I2C

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

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

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

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

一块新板子,一段波形,一堂课

你第一次在一块新设计的电路板上调通I2C的那一天。

上电,代码跑进I2C init函数,发送Start条件------逻辑分析仪上SDA和SCL都对了。从机地址发送------收到了ACK。但是读数据全是0xFF。查了半天------上拉电阻忘了焊。SDA在空闲时浮空了。

4.7kΩ电阻焊上之后,一切正常。I2C EEPROM的Device ID寄存器读回来------正是手册上写的0x2026。

这一刻你明白了:芯片之间的通信,不只是软件。它是电气、时序、协议、以及示波器上波形的综合结果。

SPI:四条线的极简主义

SPI是全双工同步主从架构的串行通信协议。四条线:

信号 名称 方向 作用
SCLK Serial Clock 主→从 时钟
MOSI Master Out, Slave In 主→从 数据
MISO Master In, Slave Out 从→主 数据
CS/SS Chip Select / Slave Select 主→从 使能从机

SPI没有标准化的协议层。速率、时钟极性(CPOL)、时钟相位(CPHA)、数据位顺序(MSB first / LSB first)、帧长度(8位/16位/可变)都由具体器件定义。这带来极大的灵活性------但也意味着每个SPI外设的驱动都是定制的。

一次典型SPI传输:

  1. 主站把CS拉低,选中从机。
  2. 主站开始在SCLK上产生时钟脉冲。
  3. 在每个SCLK边沿(CPHA决定是上升沿还是下降沿),主站在MOSI上发出1 bit,同时从站在MISO上发出1 bit。
  4. 收发完N bit后,主站把CS拉高,结束传输。

SPI是全双工------你每发一个bit,也会收到一个bit。即使你只想"读"------你也要发一段dummy数据(通常是0x00或0xFF),来产生SCLK从而让从机把数据推到MISO上。

SPI是推挽输出------每个信号线由一对互补的MOSFET驱动,可以主动拉高也能主动拉低。驱动能力较强(通常±4mA到±8mA),在短PCB走线(<10cm)下速度可达几十MHz。

穿透:S32K上配置SPI发送一个字节

让我们写一段真正跑在S32K上的SPI代码。目标:通过SPI1发送一个字节0xA5给外部ADC,然后读回ADC的转换结果。

c 复制代码
// ========== SPI1 初始化 ==========
// S32K144: SPI1 挂载在 LPSPI1 模块
// LPSPI1 寄存器基址 = 0x4002C000 (查 S32K RM 的 Memory Map)

#define LPSPI1_BASE  0x4002C000

// LPSPI 寄存器偏移 (简化版)
#define LPSPI_CR      (*(volatile uint32_t *)(LPSPI1_BASE + 0x10))  // Control
#define LPSPI_SR      (*(volatile uint32_t *)(LPSPI1_BASE + 0x14))  // Status
#define LPSPI_IER     (*(volatile uint32_t *)(LPSPI1_BASE + 0x18))  // Interrupt Enable
#define LPSPI_TCR     (*(volatile uint32_t *)(LPSPI1_BASE + 0x60))  // Transmit Cmd
#define LPSPI_CCR     (*(volatile uint32_t *)(LPSPI1_BASE + 0x80))  // Clock Config
#define LPSPI_FCR     (*(volatile uint32_t *)(LPSPI1_BASE + 0x88))  // FIFO Control
#define LPSPI_TDR     (*(volatile uint32_t *)(LPSPI1_BASE + 0xBC))  // Transmit Data
#define LPSPI_RDR     (*(volatile uint32_t *)(LPSPI1_BASE + 0xD0))  // Receive Data
#define LPSPI_MR      (*(volatile uint32_t *)(LPSPI1_BASE + 0x04))  // Module Config

void spi1_init(void)
{
    // 1. 确保 SPI1 时钟门控已打开 (PCC→SPI1)
    //    PCC_LPSPI1[CLK] = SIRCDIV2 (8MHz) 或 SPLLDIV2 (80MHz)
    //    (PCC 寄存器配置通常由 startup 代码完成,此处略)

    // 2. 复位 SPI1 模块
    LPSPI_CR = (1 << 1);  // RST = 1
    while (LPSPI_CR & (1 << 1));  // 等待复位完成 (RST 硬件清零)

    // 3. 设置模块为主模式,PCS[0] 作为 CS
    LPSPI_MR = (1 << 0);  // MR[MSTR]: Master Mode = 1

    // 4. 配置时钟分频 → SCLK = SPI模块时钟 / ((SCKDIV+2) * 2)
    //    如果模块时钟 80MHz, SCKDIV=7 → SCLK = 80/(9*2) ≈ 4.44MHz
    LPSPI_CCR = (7 << 0)     // SCKDIV = 7
              | (0 << 8)     // DBT: 数据间延迟 = 0
              | (0 << 16);   // PCSSCK: PCS→SCK 延迟 = 0

    // 5. 配置 FIFO 水位: 传输FIFO空水位 = 0 (一有数据就发)
    LPSPI_FCR = (0 << 0)     // TXWATER (传输FIFO水位)
              | (0 << 16);   // RXWATER (接收FIFO水位)

    // 6. 清除模块使能位中的休眠位
    LPSPI_CR = (1 << 0);  // MEN = 1 (模块使能)
}

你刚刚写进BR寄存器的那个分频值,最终变成了SCLK引脚上一串精确到纳秒的方波------SPI控制器的硬件分频器是一个计数器链,每数到预设值就翻转一次输出电平。

c 复制代码
// ========== SPI1 发送并接收 ==========
uint32_t spi1_transfer(uint32_t tx_data)
{
    // 等待传输FIFO有空位 (TDF = Transmit Data Flag)
    while (!(LPSPI_SR & (1 << 1)));  // 等待 SR[TDF]=1

    // 写入要发送的数据
    LPSPI_TDR = tx_data;

    // 等待接收FIFO有数据 (RDF = Receive Data Flag)
    while (!(LPSPI_SR & (1 << 0)));  // 等待 SR[RDF]=1

    // 读回接收到的数据
    return LPSPI_RDR;
}

while(!(SPI1->SR & SPI_SR_TXE)) 这一行背后,是SPI状态寄存器的一个bit。这个bit是一个D触发器的Q输出------当移位寄存器把最后一个bit推出去之后,硬件自动把TXE标志位置1。你在C代码里读到的那个1,是一个晶体管的漏极电压,经过了五层金属互联线,变成了AHB总线上的一个读数据。

c 复制代码
// ========== 使用示例 ==========
void example_spi_adc_read(void)
{
    spi1_init();

    // CS 由 LPSPI 硬件自动控制 (PCS[0])
    // 发送 ADC 命令字节 (MCP3008: start bit=1, single-ended, channel 0)
    // 命令格式: 0000 0001 1000 0000 0000 0000
    // 实际发送: 0x01 << 5 | 0x80 = 0xA0 (第一个字节)
    //          MCP3008 在第二个字节的下降沿开始输出数据

    uint32_t cmd = 0x01;  // start bit + single-ended + channel select
    // 发送: 在 SCLK 驱动下 MISO 上会收到 ADC 响应
    uint32_t adc_raw = spi1_transfer(cmd << 8);

    // adc_raw 的低 10 位是 ADC 转换结果
    uint16_t adc_value = (adc_raw >> 6) & 0x03FF;
    float voltage = adc_value * (3.3f / 1024.0f);
}

这段代码的每一个寄存器写操作,都在穿越AHB→APB Bridge→LPSPI模块内部状态机。LPSPI_TDR = tx_data执行时,数据从CPU寄存器通过AHB写周期送到APB Bridge,Bridge的PWDATA总线把数据推入LPSPI的传输FIFO。LPSPI内部的位引擎(bit engine)从SCLK边沿产生到MOSI引脚推挽输出------这个路径我们在4.1已经完整走过一遍。

I2C:两根线的优雅

I2C是半双工同步多主多从(有仲裁)的串行总线。两条线:

信号 名称 作用
SDA Serial Data 数据(双向)
SCL Serial Clock 时钟(主站驱动)

I2C的核心设计思想是开漏输出 + 上拉电阻。任何设备可以把总线拉低(N-MOS管导通到地),但不能主动拉高------拉高靠上拉电阻(Rpullup)。这个设计带来了两个好性质:

  1. 电平兼容。 只要上拉电阻接到目标VDD,不同供电电压(3.3V、5V、1.8V)的设备都可以共用同一条总线。开漏输出不驱动高电平------所以不存在3.3V设备被5V设备"反向灌电"的问题。

  2. 多主仲裁。 多个主站同时尝试控制总线时,通过监测SDA电平来判断------如果它要发"1"(释放SDA)但SDA上实际看到"0"(被别的设备拉低),说明另一个主站在同时驱动------它仲裁失败,立即停止。

I2C上拉电阻的权衡:阻值太大 → 上升沿太慢(τ = Rpullup × Cbus),阻值太小 → 设备拉低时需要下沉更多电流(I_OL = VDD/Rpullup),可能超出设备的I_OL能力。标准模式下Rpullup典型值4.7kΩ,快速模式下2.2kΩ。

I2C的幽灵:上升时间。

总线电容Cbus包括:每个设备的引脚电容(10pF/设备)、PCB走线电容(3pF/cm)、连接器电容。一条板上3个设备、10cm走线的I2C总线,Cbus约50pF。

通过一根细吸管往桶里灌水------吸管的粗细是电阻R,桶的容量是电容C。水从空桶灌到满桶的时间,就是RC时间常数。I2C的上升沿不是瞬间完成------它是开漏NMOS释放总线之后,上拉电阻通过总线电容充电的指数曲线。如果上拉电阻太大(吸管太细),曲线上升太慢,在SCL的下一个时钟沿之前SDA达不到逻辑1的判决门限------从机就看不到"1"。

上升时间t_rise = 0.847 × Rpullup × Cbus(从10%到90%)。

  • Rpullup=4.7kΩ, Cbus=50pF → t_rise ≈ 200ns。在标准模式(100kHz,周期10μs)下------没问题。
  • Rpullup=4.7kΩ, Cbus=150pF(多设备、长走线)→ t_rise ≈ 600ns。在快速模式(400kHz,周期2.5μs)下------上升沿占了周期的24%,SCL在高电平时可能达不到合法的VIH(通常0.7×VDD=3.5V)。从机认不出逻辑1。

这就是I2C慢的物理根源------不是协议规定了慢,是开漏输出+上拉电阻的RC充电过程"规定了"慢。

穿透:S32K上通过LPI2C读写I2C EEPROM

c 复制代码
// ========== LPI2C0 初始化 ==========
// S32K144: LPI2C0 寄存器基址 = 0x40066000

#define LPI2C0_BASE  0x40066000

#define LPI2C_VERID   (*(volatile uint32_t *)(LPI2C0_BASE + 0x00))
#define LPI2C_PARAM   (*(volatile uint32_t *)(LPI2C0_BASE + 0x04))
#define LPI2C_MCR     (*(volatile uint32_t *)(LPI2C0_BASE + 0x10))  // Master Config
#define LPI2C_MSR     (*(volatile uint32_t *)(LPI2C0_BASE + 0x14))  // Master Status
#define LPI2C_MIER    (*(volatile uint32_t *)(LPI2C0_BASE + 0x18))  // Master Int Enable
#define LPI2C_MDMR    (*(volatile uint32_t *)(LPI2C0_BASE + 0x40))  // Master Data Match
#define LPI2C_MCCR0   (*(volatile uint32_t *)(LPI2C0_BASE + 0x48))  // Master Clock Config
#define LPI2C_MFCR    (*(volatile uint32_t *)(LPI2C0_BASE + 0x58))  // Master FIFO Control
#define LPI2C_MFSR    (*(volatile uint32_t *)(LPI2C0_BASE + 0x5C))  // Master FIFO Status
#define LPI2C_MTDR    (*(volatile uint32_t *)(LPI2C0_BASE + 0x64))  // Master Tx Data
#define LPI2C_MRDR    (*(volatile uint32_t *)(LPI2C0_BASE + 0x70))  // Master Rx Data
#define LPI2C_SCR     (*(volatile uint32_t *)(LPI2C0_BASE + 0x110)) // Slave Config

void lpi2c0_init_100khz(void)
{
    // 确保 LPI2C0 时钟在 PCC 中已使能 (略)

    // 1. 复位模块
    LPI2C_MCR = (1 << 1);  // RST=1
    while (LPI2C_MCR & (1 << 1));  // 等待复位完成

    // 2. 主模式使能,无 FIFO
    LPI2C_MCR = (1 << 0);  // MEN=1 (Master Enable)

    // 3. 配置时钟: 设模块时钟=48MHz, 目标SCL=100kHz
    //    SCL = 模块时钟 / (CLKHI+1 + CLKLO+1 + SCL_LATENCY)
    //    其中 SCL_LATENCY ≈ 2 (滤波毛刺)
    //    目标 SCL=100kHz; SCL=48MHz/(240+240+2)=100kHz
    LPI2C_MCCR0 = (239 << 0)       // CLKLO = 239
                | (239 << 8)       // CLKHI = 239
                | (3 << 24)       // DATAVD = 3 (数据有效延迟)
                | (3 << 16);      // SETHOLD = 3 (建立保持)
}

// ========== I2C 起始条件 (Start) ==========
void i2c_start(void)
{
    // MSTART: 在Master模式下, 发送START条件
    // 写任意值到MTDR即可触发 (CMD字段=0x4=START)
    LPI2C_MTDR = ((0x4 & 0x7) << 8);  // CMD=START
    while (!(LPI2C_MSR & (1 << 0)));   // 等待 TDF (Transmit Data Flag)
    //    MCR[START] 位会自动清除
}

// ========== I2C 停止条件 (Stop) ==========
void i2c_stop(void)
{
    LPI2C_MTDR = ((0x6 & 0x7) << 8);  // CMD=STOP
}

I2C的Start条件------SDA在SCL高电平时拉低------背后是两个开漏NMOS的依次开关。先拉SDA(NMOS导通,把线拉到地),再拉SCL。两根线的电平变化通过上拉电阻充电到VDD。你看到的"Start条件",在示波器上是两个下降沿,间隔几个微秒。

c 复制代码
// ========== I2C 发送一个字节 (含从机地址) ==========
uint8_t i2c_write_byte(uint8_t data)
{
    // 等待传输FIFO可用
    while (!(LPI2C_MSR & (1 << 0)));  // TDF

    // 写入数据 + 发送命令 CMD=TRANSMIT
    LPI2C_MTDR = (data & 0xFF)
               | ((0x0 & 0x7) << 8);  // CMD=TRANSMIT DATA

    // 等待传输完成 (TDF again after data sent)
    while (!(LPI2C_MSR & (1 << 0)));

    // 读回 ACK/NACK: MSR[RXAK]=1 表示收到 NACK
    if (LPI2C_MSR & (1 << 2))  // NACK detected
        return 1;  // NACK
    return 0;       // ACK
}

// ========== I2C 接收一个字节 ==========
uint8_t i2c_read_byte(uint8_t ack)
{
    // 等待 FIFO 可用
    while (!(LPI2C_MSR & (1 << 0)));

    if (ack) {
        // CMD=RECEIVE + ACK (发送ACK给从机)
        LPI2C_MTDR = (0x1 & 0x7) << 8;  // CMD=Rx ACK
    } else {
        // CMD=RECEIVE + NACK (发送NACK,通常是最后一字节)
        LPI2C_MTDR = (0x2 & 0x7) << 8;  // CMD=Rx NACK
    }

    // 等待接收FIFO有数据 (MRF = Master Receive Flag)
    while (!(LPI2C_MSR & (1 << 1)));  // RDF

    return (uint8_t)(LPI2C_MRDR & 0xFF);
}

// ========== 完整的 I2C EEPROM 读取 ==========
// 读 EEPROM (如 AT24C02) 地址 0x00 处的一个字节
uint8_t eeprom_read_byte(uint8_t slave_addr, uint8_t mem_addr)
{
    uint8_t data;

    // --- 第一阶段: 写操作 → 发送存储器内部地址 ---
    i2c_start();
    if (i2c_write_byte((slave_addr << 1) | 0x00)) {  // R/W=0 (写)
        // NACK --- 从机不在总线上
        i2c_stop();
        return 0xFF;
    }
    i2c_write_byte(mem_addr);  // 发送存储器内部地址
    // 注意: EEPROM 在这个阶段不需要 STOP, 而是发 Repeated START

    // --- 第二阶段: 读操作 → 接收数据 ---
    i2c_start();  // Repeated START
    if (i2c_write_byte((slave_addr << 1) | 0x01)) {  // R/W=1 (读)
        i2c_stop();
        return 0xFF;
    }
    data = i2c_read_byte(0);  // 读一字节, 回复 NACK (最后字节)
    i2c_stop();

    return data;
}

// ========== 使用示例 ==========
void example_eeprom(void)
{
    lpi2c0_init_100khz();
    uint8_t device_id = eeprom_read_byte(0x50, 0x00);
    // device_id 应该是手册上写的 0x2026 对应寄存器内容
}

这段代码的背后,I2C控制器的状态机在SCL的每个上升沿采样SDA,在SCL的低电平区间更新SDA。 ACK位的检测是硬件自动完成的------LPI2C控制器在第9个SCL脉冲的上升沿采样SDA,如果为高(NACK),置位MSRNACK。你的代码读取该标志决定下一步。

时钟延长(Clock Stretching):从机的"等一等"

某些I2C从机(包括安全芯片如NXP SE050)在处理接收到的数据时,还没有准备好下一个字节------它会主动把SCL拉低。主机必须检测到SCL被拉低后暂停发送下一个时钟脉冲,等SCL释放(变高)后再继续。

这就是时钟延长(Clock Stretching)。它不是bug,是I2C spec明确定义的行为。

实际问题来了:不是每个MCU的I2C控制器都支持时钟延长。STM32F1系列的某些I2C硬件实现默认不支持------需要切换到硬件I2C模式并启用I2C_ClockStretching特性。S32K的LPI2C模块是支持时钟延长的------它在每个SCL释放后自动检测SCL实际电平,在SCL被从机拉低时进入等待。

如果你调了一个下午,I2C每次都停在从机地址发送完成后------先查上拉电阻,再查时钟延长。

SPI的物理层:信号完整性

SPI的SCLK信号从MCU引脚出来,经过PCB走线到达外设。如果这条走线太长了,会发生什么?

反射。 走线的特征阻抗Z₀通常50Ω(微带线)或90Ω(差分对)。MCU的输出驱动器的输出阻抗通常20-40Ω,与50Ω不完全匹配。信号到达外设端时,外设的输入阻抗很高(通常>100kΩ),相当于开路------信号几乎完全反射回来。反射波叠加在原信号上,在MCU端看到台阶状的"振铃"。如果反射波的延迟与SCLK周期可比,上一个bit的反射会在下一个bit的采样窗口内产生干扰。

对于SPI在20MHz以上、走线超过5cm------建议在靠近外设端加一个22-33Ω的串联终端电阻(series termination)。这个电阻与驱动器输出阻抗之和≈50Ω,与走线特征阻抗匹配,吸收反射。

串扰(Crosstalk)。 MOSI和SCLK是相邻的走线。SCLK的快速跳变(边沿斜率可能达到1-2V/ns)通过互容互感耦合到MOSI上。如果MOSI的数据在SCLK的采样沿附近被干扰------采样到的bit可能错误。"串扰不是杀死信号------它是在错误的时刻改了信号的值。"

SPI也有自己的串扰问题。SCLK是一根高频方波------20MHz的时钟意味着每25ns翻转一次。这根线上的快速跳变通过PCB上相邻走线的寄生电容(大约0.5pF/cm)耦合到旁边的MISO或MOSI线上。如果你在示波器上看MISO信号,你会看到SCLK每次跳变时,MISO上都有一个微小的尖刺------这就是串扰。在低速率下这个尖刺很快消失,不影响数据采样。但在20MHz下,尖刺的持续时间可能占到半个时钟周期------足以让从机误读一个bit。解决方案:在SCLK和MOSI/MISO之间加一根GND走线(保护线),或者简单地拉开走线间距。

SPI vs I2C:两种哲学,一种智慧

维度 SPI I2C
线数 4 (+ 每从机1根CS) 2 (所有设备共享)
双工 全双工 半双工
速度 几十MHz 100k-3.4M
输出类型 推挽 开漏+上拉
协议层 无标准 标准(地址、ACK/NACK)
多主 不支持(硬件CS) 支持(仲裁)
从机数量 CS引脚数限制 地址空间限制(7/10位)
引脚效率
功耗 高(推挽持续驱动) 低(静态时均为高)

SPI和I2C诞生在1980年代。当时没有人想到:40年后,它们会在汽车的每个ECU里运转------SPI连接MCU和高边驱动器,I2C连接MCU和安全芯片。

SPI设计者的哲学:"简单、快速、灵活"------没有固定协议、没有设备状态机、推挽输出可以跑得非常快。但他们把"错误处理、协议兼容性、设备管理"全部推给了软件开发人员。

I2C设计者的哲学:"最少引脚、共享总线、多主协作"------只用两条线连接几十个设备,用低成本的PCB和低功耗的方式解决问题。代价是速度慢、上拉电阻调校烦人、时钟延长难处理。

你在选SPI还是I2C的时候,不是在比较"哪个更好"------你是在评估:引脚数量还剩几根?速率100kbps够不够?一个主站对几个从站?PCB走线多长?供电电压几伏?功耗预算多少?每一个约束叠加在一起,自然地把你导向某个方案。

没有绝对的好技术------只有"在约束下最合适的方案"。工程的选择,从来是语境的选择。


本篇小结

今天我们做了一件事:在引脚、速率、功耗、PCB面积的多重约束下,理解SPI和I2C各自的哲学。

关键结论:

  1. SPI是"快而贵"的哲学:推挽输出、全双工、无标准协议------把复杂性推给软件,换取极致速率。代价是每增加一个从机就要多一根CS线。
  2. I2C是"省而慢"的哲学:开漏输出、两线共享总线、标准地址与ACK/NACK机制------硬件复杂度最低。代价是上拉电阻调校繁琐,速率受RC时间常数限制。
  3. 选择不是"哪个更好",而是"约束下哪个最合适":引脚还剩几根?速率需求多大?走线多长?功耗预算多少?每一个约束叠加在一起,自然地把你导向某个方案。

下一节,信号走出芯片封装,在两个ECU之间、几米长的双绞线上奔跑------CAN总线的逐位仲裁和差分信号,正在物理世界的电磁地狱里等待你。

【下集预告】

SPI和I2C解决的是片间通信------一颗MCU和一颗EEPROM之间,几厘米的PCB走线。

但一辆车上有几十个ECU。发动机控制单元在发动机舱,ABS控制器在制动主缸旁边,仪表盘在方向盘后面。它们分布在车身的各个角落,需要通过几米长的线束互相交换数据。

这就引出下一个问题:让一辆车上的几十个ECU对话。

CAN总线------1983年博世工程师的答案。它不是最快的,不是最灵活的,但它是"汽车约束下"的最优解。双绞线、差分信号、逐位仲裁、确定性延迟------每一项设计都在回答同一个问题:"在火花塞放电的电磁地狱里、在-40°C到125°C的温度范围里、在10万公里振动的机械环境里------怎么可靠地传递一个转速信号?"

CAN最天才的设计不在物理层,在ID域。它用一个字段同时解决了"这是什么数据"和"谁先说"两个问题。逐位仲裁------在协议层面实现了信息论级的优雅。

相关推荐
郑寿昌2 小时前
清华开源智能体PilotDeck:智能路由技术大幅降低AI落地成本
人工智能·开源
不脱发的程序猿2 小时前
如何创建一个标准Skill,让嵌入式经验真正复用起来
人工智能·单片机·嵌入式硬件·嵌入式·skill
冬奇Lab12 小时前
每日一个开源项目(第105篇):Twenty - 跳出 Salesforce 的圈套,定义现代开源 CRM
前端·后端·开源
GitCode官方14 小时前
开源鸿蒙 PC 直播回顾|从环境搭建到真机验证:鸿蒙 PC 命令行迁移全链路。
华为·开源·harmonyos
IAR Systems15 小时前
软件定义汽车:构建更安全、更智能的汽车应用软件
安全·汽车·嵌入式·iar
阿宝哥16 小时前
国产开源 TTS 杀疯了:2B 参数、支持 30 种语言,语音克隆和声音设计全都有!
开源·aigc
Jason_zhao_MR18 小时前
纳秒级抖动×24小时零丢帧:RK3576工业级EtherCAT主站全拆解
大数据·人工智能·单片机·嵌入式
MoonBit月兔18 小时前
MoonBit开源创新大赛山东&重庆高校行——与青年开发者共探AI原生软件新未来
开发语言·人工智能·开源·ai-native·moonbit
API开发平台18 小时前
开源 API 开发平台 5.1.0 发布
低代码·开源