CAN 通信协议学习讲义(带图文 + C 语言代码)

这份讲义适合从 零基础到能上手写代码。你会学到:

  1. CAN 是什么,为什么工业和汽车里大量使用它

  2. 一帧 CAN 报文长什么样

  3. 仲裁、位填充、ACK、CRC、错误处理分别在干什么

  4. 如何在 MCU 上用 C 代码完成发送与接收

  5. 学完后该怎么继续进阶到 CAN FD、DBC、上位机调试


一、CAN 是什么

CAN(Controller Area Network ,控制器局域网)是一种多主机、基于消息、实时性很强的串行通信总线。

它最早用于汽车电子,现在广泛用于:

  • 汽车 ECU 通信

  • 工业控制

  • 机器人

  • 医疗设备

  • 电池管理系统(BMS)

  • 电机控制器

1.1 CAN 的几个关键特点

  • 两根总线线缆:CAN_H 和 CAN_L

  • 多节点挂载:很多设备能接在同一条总线上

  • 按报文 ID 仲裁:不是按设备地址抢占,而是谁的 ID 更"优先"

  • 抗干扰强:差分传输

  • 可靠性高:有 CRC、ACK、错误重发、错误状态机


二、先建立整体认识:CAN 系统长什么样

一个典型节点通常由 3 部分组成:

  • MCU / CPU:运行你的应用代码

  • CAN 控制器:负责打包/解包 CAN 帧、仲裁、CRC 等

  • CAN 收发器:把逻辑信号转换成总线差分电平

2.1 系统结构图

复制代码
┌──────────┐   TX/RX   ┌──────────────┐   CANH/CANL   ┌────────────┐
│   MCU    │──────────▶│ CAN 控制器   │──────────────▶│ CAN 收发器 │
│(应用程序)│◀──────────│(协议处理)    │◀──────────────│(物理层)    │
└──────────┘           └──────────────┘               └─────┬──────┘
                                                             │
=============================================================│==== CAN 总线
                                                             │
┌──────────┐           ┌──────────────┐               ┌─────▼──────┐
│   MCU    │──────────▶│ CAN 控制器   │──────────────▶│ CAN 收发器 │
│(另一个节点)│◀────────│(协议处理)    │◀──────────────│(物理层)    │
└──────────┘           └──────────────┘               └────────────┘

2.2 终端电阻

CAN 总线两端通常各接一个 120Ω 终端电阻,等效 60Ω。

复制代码
120Ω                                        120Ω
[节点]---+------------------总线-------------------+---[节点]
         |                                         |
       其他节点                                   其他节点

没有正确终端匹配时,波形会反射,通信会不稳定。


三、物理层:为什么 CAN 抗干扰强

CAN 使用差分信号

  • 隐性位(Recessive,逻辑 1):CAN_H 与 CAN_L 电压接近

  • 显性位(Dominant,逻辑 0):CAN_H 升高,CAN_L 降低

3.1 差分示意图

复制代码
隐性位(1):
CAN_H  ≈ 2.5V
CAN_L  ≈ 2.5V
差分电压接近 0V

显性位(0):
CAN_H  ≈ 3.5V
CAN_L  ≈ 1.5V
差分电压约 2V

3.2 为什么显性位能"压住"隐性位

总线上只要任何一个节点发送显性位 0 ,总线就会表现为显性位。

这就是 CAN 仲裁机制的基础。

可以把它理解为:

  • 1 = 放手

  • 0 = 强驱动

因此 0 会覆盖 1


四、CAN 帧类型

常见的 CAN 帧有 4 类:

  1. 数据帧(Data Frame):真正传输数据

  2. 远程帧(Remote Frame):请求别人发送数据

  3. 错误帧(Error Frame):发现错误时发出

  4. 过载帧(Overload Frame):延迟下一帧

实际开发里最常见的是:

  • 标准数据帧(11 位 ID)

  • 扩展数据帧(29 位 ID)


五、最重要的内容:数据帧结构


5.1 标准帧(11 位 ID)结构

复制代码
SOF | Arbitration | Control | Data | CRC | ACK | EOF

更细分:
SOF | ID(11) | RTR | IDE | r0 | DLC(4) | DATA(0~8字节) | CRC(15) | CRC Delim | ACK | ACK Delim | EOF(7)

5.2 标准帧结构图

复制代码
┌───┬───────────┬────┬────┬────┬────────┬──────────────────┬────────────┬───────┬────────┐
│SOF│  ID[10:0] │RTR │IDE │ r0 │  DLC   │   DATA 0~8 Byte  │   CRC域    │ ACK域 │  EOF   │
└───┴───────────┴────┴────┴────┴────────┴──────────────────┴────────────┴───────┴────────┘

5.3 各字段作用

SOF(Start of Frame)

帧起始,1 位显性位,告诉所有节点:一帧开始了。

ID(标识符)

不是设备地址,而是报文优先级 + 报文语义

  • ID 越小,优先级越高

  • 例如:0x100 会比 0x300 更优先发出去

RTR
  • 0:数据帧

  • 1:远程帧

IDE
  • 0:标准帧(11 位 ID)

  • 1:扩展帧(29 位 ID)

DLC(Data Length Code)

表示数据长度,标准 CAN 中一般是 0~8 字节。

DATA

真正的数据载荷。

CRC

用于检错。

ACK

接收成功的节点会在 ACK 槽中写入显性位,表示"我收到了"。

EOF

帧结束。


六、扩展帧(29 位 ID)

扩展帧把 ID 从 11 位扩展为 29 位,适合报文种类更多、网络更复杂的系统。

6.1 扩展帧示意

复制代码
SOF | 11位ID | SRR | IDE | 18位扩展ID | RTR | r1 | r0 | DLC | DATA | CRC | ACK | EOF

6.2 标准帧和扩展帧对比

项目 标准帧 扩展帧
ID 长度 11 位 29 位
最大分类能力 较少 更多
总线效率 更高 略低
常见场景 小中型系统 大型复杂系统

很多工业项目如果报文不多,优先用标准帧,更简单、效率更高。


七、CAN 的灵魂:仲裁机制

CAN 是多主机总线 ,多个节点都能主动发报文。

那如果两个节点同时发怎么办?

答案:按 ID 位级仲裁

7.1 仲裁规则

  • 节点一边发,一边监听总线

  • 发送 1(隐性) ,但总线上读到 0(显性),说明输了

  • 输掉仲裁的节点立即停止发送,等待下一次机会

  • 赢的节点继续发送,且不会破坏当前帧

这叫:无损仲裁

7.2 仲裁示例

假设两个节点同时发:

  • 节点 A 发 ID = 0x100

  • 节点 B 发 ID = 0x120

把关键位拿来对比:

复制代码
A: 00100000000
B: 00100100000
         ↑
在某一位上,A 发 0,B 发 1
总线结果为 0
B 发现自己发的是 1,读到的是 0,于是退出仲裁

因此:ID 数值更小的报文优先级更高。

7.3 仲裁图示

复制代码
时间 →

节点A: 0 0 1 0 0 0 ...
节点B: 0 0 1 0 1 0 ...
总线 : 0 0 1 0 0 0 ...
                  ↑
            B 在此处失败退出

八、位填充(Bit Stuffing)

为了让接收端保持同步,CAN 规定:

在 SOF 到 CRC 序列之间,如果连续出现 5 个相同位 ,发送端必须自动插入一个相反位

8.1 例子

原始比特流:

复制代码
111110

位填充后:

复制代码
11111 0 0

接收端会自动去掉填充位。

8.2 为什么要这样做

因为串行通信需要时钟同步。

长时间全是 0 或全是 1,会让采样点漂移。


九、ACK、CRC、重发:CAN 为什么可靠

9.1 CRC

发送端对报文做 CRC,接收端也计算 CRC:

  • 一样:认为报文完整

  • 不一样:判定出错

9.2 ACK

ACK 槽默认是隐性位。

如果至少有一个节点成功接收,它会把 ACK 槽拉成显性位。

也就是说:

  • 发送端发完后,看看 ACK 位有没有被别人拉低

  • 没有 ACK,通常说明没人正确收到

9.3 自动重发

CAN 控制器通常支持自动重发:

  • 仲裁失败 → 不是错误,稍后重发

  • CRC/ACK 等错误 → 可自动重发


十、错误类型与错误状态机

CAN 的强大之处之一在于:它不仅能发现错误,还能"自我隔离"故障节点。

10.1 常见错误类型

  • 位错误(Bit Error)

  • 填充错误(Stuff Error)

  • CRC 错误(CRC Error)

  • 格式错误(Form Error)

  • ACK 错误(ACK Error)

10.2 节点状态

CAN 节点通常有 3 种状态:

  1. Error Active:正常工作

  2. Error Passive:错误变多,但还能通信

  3. Bus Off:严重错误,主动退出总线

10.3 状态变化示意

复制代码
Error Active  --错误累计增加-->  Error Passive  --继续增加-->  Bus Off

当节点进入 Bus Off,一般需要软件重新初始化控制器才能恢复。


十一、波特率与位时序

实际配置 CAN 时,除了"500kbps / 1Mbps"这样的总速率,还要配置位时间分段

一个比特时间常被分成:

  • Sync Segment

  • Prop Segment

  • Phase Segment 1

  • Phase Segment 2

很多芯片还会把它们合并成更常见的配置项:

  • Prescaler

  • BS1

  • BS2

  • SJW

11.1 位时序示意

复制代码
| Sync |     BS1      |   BS2   |
|------|--------------|---------|
                ^
              采样点

11.2 常见波特率

  • 125 kbps

  • 250 kbps

  • 500 kbps

  • 1 Mbps

11.3 入门建议

刚开始学习时,你只要记住:

  • 所有节点波特率必须一致

  • 终端电阻要正确

  • 线长和波特率有关:速率越高,可允许线越短


十二、报文过滤

如果总线上有很多报文,但你的节点只关心其中一部分,就可以使用过滤器(Filter)

12.1 过滤器的作用

  • 只接收感兴趣的 ID

  • 减少 MCU 中断负担

  • 提高系统效率

12.2 常见过滤模式

  • 列表模式:只收某几个固定 ID

  • 掩码模式:按位匹配一类 ID

例如:

  • 0x1000x1010x102

  • 或者收 0x1xx 这一类


十三、一个完整的理解例子

假设有 3 个节点:

  • 电机控制器:发送转速

  • 电池管理器:发送电压电流

  • 仪表:接收数据显示

约定:

ID 含义 发送节点 数据
0x101 电机转速 电机控制器 2 字节 rpm
0x102 电池电压 BMS 2 字节 mV
0x103 电池电流 BMS 2 字节 mA

若电机转速报文和电池电压报文同时发:

  • 0x101 < 0x102

  • 所以 0x101 优先发送


十四、开发中你最常碰到的几个坑

14.1 只有自己能发,别人收不到

常见原因:

  • 终端电阻不对

  • 收发器接线反了

  • 波特率不一致

  • 共地问题

14.2 能看到帧,但总报错

常见原因:

  • 位时序配置不匹配

  • 总线过长,波特率太高

  • 干扰太强

14.3 发不出去,一直重发

常见原因:

  • 总线上没有其他节点 ACK

  • 节点进入错误状态

  • 控制器邮箱未释放


十五、开始写代码前,你需要知道的软件抽象

不同 MCU(STM32、NXP、TI、ESP32、S32K 等)底层寄存器不同,

但从应用层看,你可以把 CAN 理解成下面几个接口:

  • can_init():初始化

  • can_send():发送一帧

  • can_receive():接收一帧

  • can_set_filter():配置过滤器

为了便于学习,下面先给你一份平台无关的 C 语言示例


十六、C 语言数据结构设计

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

#define CAN_ID_STD  0
#define CAN_ID_EXT  1

#define CAN_RTR_DATA   0
#define CAN_RTR_REMOTE 1

typedef struct
{
    uint32_t id;         // 11位或29位ID
    uint8_t  id_type;    // 标准帧/扩展帧
    uint8_t  rtr;        // 数据帧/远程帧
    uint8_t  dlc;        // 数据长度 0~8
    uint8_t  data[8];    // 数据区
} can_frame_t;

这个结构体表达的是"应用层眼中的一帧 CAN 数据"。


十七、平台无关的发送/接收接口

复制代码
// 下面这些函数通常需要你根据具体芯片驱动去实现
int can_hw_init(uint32_t baudrate);
int can_hw_send(const can_frame_t *frame);
int can_hw_receive(can_frame_t *frame);
int can_hw_set_filter(uint32_t id, uint32_t mask, uint8_t id_type);

十八、初始化代码示例

复制代码
int can_init(void)
{
    uint32_t baudrate = 500000; // 500kbps

    if (can_hw_init(baudrate) != 0)
    {
        printf("CAN init failed!\n");
        return -1;
    }

    // 示例:接收标准ID 0x101 ~ 0x1FF
    // 掩码写法依平台不同而不同,这里用概念表示
    if (can_hw_set_filter(0x100, 0x700, CAN_ID_STD) != 0)
    {
        printf("CAN filter config failed!\n");
        return -2;
    }

    printf("CAN init ok.\n");
    return 0;
}

注:不同芯片的过滤器编码方式差异很大,上面的 idmask 是教学示意。


十九、发送一帧:C 语言示例

19.1 发送一个电机转速报文

假设:

  • 报文 ID = 0x101

  • 数据 2 字节表示转速 rpm

    int can_send_motor_speed(uint16_t rpm)
    {
    can_frame_t frame;
    memset(&frame, 0, sizeof(frame));

    复制代码
      frame.id      = 0x101;
      frame.id_type = CAN_ID_STD;
      frame.rtr     = CAN_RTR_DATA;
      frame.dlc     = 2;
    
      frame.data[0] = (uint8_t)(rpm >> 8);
      frame.data[1] = (uint8_t)(rpm & 0xFF);
    
      return can_hw_send(&frame);

    }

19.2 调用示例

复制代码
int main(void)
{
    if (can_init() != 0)
        return -1;

    while (1)
    {
        uint16_t rpm = 1500;

        if (can_send_motor_speed(rpm) != 0)
        {
            printf("send failed\n");
        }

        // 这里省略延时函数
    }

    return 0;
}

二十、接收一帧:C 语言示例

20.1 接收并解析

复制代码
void can_poll_receive(void)
{
    can_frame_t frame;

    if (can_hw_receive(&frame) == 0)
    {
        printf("RX: ID=0x%lX DLC=%d\n", (unsigned long)frame.id, frame.dlc);

        if (frame.id_type == CAN_ID_STD && frame.id == 0x101 && frame.dlc >= 2)
        {
            uint16_t rpm = ((uint16_t)frame.data[0] << 8) | frame.data[1];
            printf("Motor speed = %u rpm\n", rpm);
        }
    }
}

20.2 主循环中轮询接收

复制代码
int main(void)
{
    if (can_init() != 0)
        return -1;

    while (1)
    {
        can_poll_receive();
    }

    return 0;
}

二十一、中断方式接收(更常见)

实际项目里,接收往往不是轮询,而是:

  • CAN 接收 FIFO 来报文

  • 触发中断

  • 中断中读取帧

  • 放入软件队列

  • 主循环再处理

21.1 典型思路图

复制代码
总线来数据
   │
   ▼
CAN FIFO
   │
   ▼
中断服务函数 ISR
   │
   ▼
软件环形缓冲区
   │
   ▼
主循环 / 任务线程解析

21.2 简化示例代码

复制代码
#define CAN_RX_QUEUE_SIZE 16

static can_frame_t g_can_rx_queue[CAN_RX_QUEUE_SIZE];
static volatile uint8_t g_rx_head = 0;
static volatile uint8_t g_rx_tail = 0;

static int can_queue_push(const can_frame_t *frame)
{
    uint8_t next = (uint8_t)((g_rx_head + 1) % CAN_RX_QUEUE_SIZE);
    if (next == g_rx_tail)
    {
        // 队列满
        return -1;
    }

    g_can_rx_queue[g_rx_head] = *frame;
    g_rx_head = next;
    return 0;
}

static int can_queue_pop(can_frame_t *frame)
{
    if (g_rx_head == g_rx_tail)
    {
        return -1;
    }

    *frame = g_can_rx_queue[g_rx_tail];
    g_rx_tail = (uint8_t)((g_rx_tail + 1) % CAN_RX_QUEUE_SIZE);
    return 0;
}

21.3 中断回调示意

复制代码
void can_rx_isr_callback(void)
{
    can_frame_t frame;

    if (can_hw_receive(&frame) == 0)
    {
        (void)can_queue_push(&frame);
    }
}

21.4 主循环处理

复制代码
void app_process_can_messages(void)
{
    can_frame_t frame;

    while (can_queue_pop(&frame) == 0)
    {
        switch (frame.id)
        {
            case 0x101:
            {
                if (frame.dlc >= 2)
                {
                    uint16_t rpm = ((uint16_t)frame.data[0] << 8) | frame.data[1];
                    printf("[APP] rpm = %u\n", rpm);
                }
                break;
            }

            case 0x102:
            {
                if (frame.dlc >= 2)
                {
                    uint16_t mv = ((uint16_t)frame.data[0] << 8) | frame.data[1];
                    printf("[APP] battery voltage = %u mV\n", mv);
                }
                break;
            }

            default:
                break;
        }
    }
}

二十二、如果你用的是 STM32,代码通常会长什么样

下面给你一个接近实际项目的 STM32 HAL 风格示例,用于帮助你把上面的抽象映射到真实平台。

注意:不同 STM32 系列(bxCAN / FDCAN)接口不同,下面更接近传统 bxCAN 风格思路。

22.1 发送示例(HAL 风格)

复制代码
#include "stm32f1xx_hal.h"

extern CAN_HandleTypeDef hcan;

int stm32_can_send_std(uint16_t std_id, uint8_t *data, uint8_t len)
{
    CAN_TxHeaderTypeDef tx_header;
    uint32_t tx_mailbox;

    tx_header.StdId = std_id;
    tx_header.ExtId = 0;
    tx_header.IDE   = CAN_ID_STD;
    tx_header.RTR   = CAN_RTR_DATA;
    tx_header.DLC   = len;
    tx_header.TransmitGlobalTime = DISABLE;

    if (HAL_CAN_AddTxMessage(&hcan, &tx_header, data, &tx_mailbox) != HAL_OK)
    {
        return -1;
    }

    return 0;
}

22.2 接收回调示例

复制代码
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan)
{
    CAN_RxHeaderTypeDef rx_header;
    uint8_t data[8];

    if (HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &rx_header, data) != HAL_OK)
    {
        return;
    }

    if (rx_header.IDE == CAN_ID_STD && rx_header.StdId == 0x101 && rx_header.DLC >= 2)
    {
        uint16_t rpm = ((uint16_t)data[0] << 8) | data[1];
        printf("RPM=%u\n", rpm);
    }
}

22.3 初始化流程一般包含

复制代码
1. 配置 GPIO(CAN TX / RX)
2. 配置 CAN 波特率与位时序
3. 配置过滤器
4. 启动 CAN 外设
5. 打开接收中断

二十三、如何设计自己的 CAN 协议

CAN 只规定"怎么传",不规定"数据含义"。

所以项目里你还需要设计自己的应用层协议

23.1 建议先定义一张报文表

报文 ID 名称 周期 发送者 字节定义
0x101 MotorSpeed 10ms 电机控制器 Byte0~1: rpm
0x102 BatteryVolt 100ms BMS Byte0~1: mV
0x103 BatteryCurr 100ms BMS Byte0~1: mA

23.2 数据定义建议

  • 明确大小端

  • 明确比例因子(scale)

  • 明确偏移量(offset)

  • 明确物理单位

  • 明确超时策略

例如:

  • Byte0~1:转速原始值

  • 物理值 = raw × 1 rpm

或者:

  • Byte0~1:温度

  • 物理值 = raw × 0.1 - 40 ℃


二十四、上位机抓包时你会看到什么

调试 CAN 时常用工具:

  • USB-CAN 分析仪

  • PCAN、ZLG、CANalyst、Kvaser 等

  • SocketCAN(Linux)

你通常会看到:

复制代码
ID: 0x101   DLC: 2   DATA: 05 DC

05 DC 解析成 16 位:

复制代码
0x05DC = 1500

于是可得:

复制代码
转速 = 1500 rpm

二十五、学习 CAN 时最该练的 5 个能力

  1. 看懂一帧结构

  2. 看懂 ID 和优先级

  3. 能把物理量打包成 8 字节数据

  4. 能写收发代码

  5. 能用抓包工具定位问题


二十六、给你一个练习题

请你自己写一个报文:

  • ID = 0x120

  • 数据含义:

    • Byte0~1:电压(单位 0.1V)

    • Byte2~3:电流(单位 0.01A)

  • 假设:

    • 电压 = 24.3V

    • 电流 = 5.67A

26.1 你可以这样思考

电压原始值:

复制代码
24.3 / 0.1 = 243 = 0x00F3

电流原始值:

复制代码
5.67 / 0.01 = 567 = 0x0237

所以 4 个字节可以写成:

复制代码
00 F3 02 37

26.2 参考代码

复制代码
int can_send_power_info(void)
{
    can_frame_t frame;
    memset(&frame, 0, sizeof(frame));

    uint16_t voltage_raw = 243;
    uint16_t current_raw = 567;

    frame.id      = 0x120;
    frame.id_type = CAN_ID_STD;
    frame.rtr     = CAN_RTR_DATA;
    frame.dlc     = 4;

    frame.data[0] = (uint8_t)(voltage_raw >> 8);
    frame.data[1] = (uint8_t)(voltage_raw & 0xFF);
    frame.data[2] = (uint8_t)(current_raw >> 8);
    frame.data[3] = (uint8_t)(current_raw & 0xFF);

    return can_hw_send(&frame);
}

二十七、标准 CAN 与 CAN FD

你现在学的是经典 CAN(Classic CAN),它的特点是:

  • 数据段最多 8 字节

  • 速率通常到 1Mbps

CAN FD 是升级版:

  • 数据段可超过 8 字节(最高 64 字节)

  • 数据阶段速率可以更高

建议学习路线:

  1. 先完全吃透经典 CAN

  2. 再学 CAN FD

  3. 再接触 J1939、CANopen、UDS 等上层协议


二十八、一张总复习图

复制代码
                ┌─────────────────────────────┐
                │         CAN 总线核心        │
                └─────────────┬───────────────┘
                              │
         ┌────────────────────┼────────────────────┐
         │                    │                    │
         ▼                    ▼                    ▼
   物理层差分传输          帧结构与ID仲裁        错误检测与恢复
  (CANH / CANL)          (无损仲裁、优先级)    (CRC/ACK/错误状态)
         │                    │                    │
         └────────────────────┼────────────────────┘
                              │
                              ▼
                       MCU 软件开发实现
                 (初始化 / 过滤 / 发送 / 接收)

二十九、学习建议:怎么学得最快

第 1 阶段:理解概念

你先重点掌握:

  • 标准帧结构

  • 仲裁

  • ACK

  • CRC

  • 错误状态

第 2 阶段:上手代码

你要会写:

  • 初始化

  • 发一帧标准数据帧

  • 收一帧并解析

第 3 阶段:结合工具调试

你要会:

  • 看抓包

  • 看 ID

  • 看 DLC

  • 看 data 字节

  • 看波特率配置

第 4 阶段:协议设计

你再学:

  • 报文表设计

  • 信号缩放

  • 周期报文与事件报文

  • 超时监控


三十、最后用几句话把 CAN 讲透

你可以把 CAN 理解成:

一条允许很多设备一起说话的总线。

每条消息都有一个 ID。

ID 小的消息优先说。

所有人都能听到消息。

收到的人会 ACK。

出错了会自动检测、重发,严重故障的节点会自己退出总线。

如果你把下面这 6 个关键词彻底理解了,CAN 就算真正入门了:

  • 差分传输

  • 数据帧

  • ID 仲裁

  • 位填充

  • CRC + ACK

  • 错误状态机


三十一、你下一步最值得学的内容

推荐顺序:

  1. 我带你继续学:CAN 帧逐位解析

  2. 我带你学:STM32 上的 CAN 实战配置

  3. 我带你学:CAN FD 与经典 CAN 的区别

  4. 我带你学:如何设计一套自己的 CAN 应用层协议


三十二、给初学者的一句提醒

刚开始不要一上来死记寄存器。

先把"总线怎么工作 "和"一帧报文怎么流动"搞懂,再去看具体芯片驱动,你会学得快很多。

相关推荐
xht08322 小时前
PHP vs C语言:核心差异全解析
c语言·开发语言·php
yoothey2 小时前
Java字节流与字符流核心笔记(问答+考点复盘)
java·开发语言·笔记
查古穆2 小时前
python进阶-Pydantic模型
开发语言·python
沐知全栈开发2 小时前
Bootstrap4 导航栏
开发语言
kyriewen112 小时前
异步编程:从“回调地狱”到“async/await”的救赎之路
开发语言·前端·javascript·chrome·typescript·ecmascript·html5
AI+程序员在路上2 小时前
嵌入式软件技术大全
linux·开发语言·arm开发·单片机
吴声子夜歌2 小时前
JavaScript——数据类型
开发语言·javascript·ecmascript
星空3 小时前
RAG学习第一节
学习
知识分享小能手3 小时前
MongoDB入门学习教程,从入门到精通,MongoDB入门指南 —— 知识点详解(2)
数据库·学习·mongodb