储能系统CAN通信抽象层解读

这是一篇从我自己维护的储能后备管理系统里,挑出 CAN 通信那一块做的复盘。项目基于 Linux + SocketCAN,App 层是纯 C 写的。下面讲的每一个设计、每一行代码,都是真实跑在设备上的东西,不是纸上谈兵。


开头先说清楚:我为什么要单独写 CAN 这一层

做储能的人都知道,BMS(电池管理系统)和 PCS(储能变流器)之间,最核心、最不能出错的通信就是 CAN。电池的电压、电流、温度、SOC、告警状态,全都要靠 CAN 帧在设备之间传来传去。CAN 这条总线要是抖动一下,轻则数据丢帧,重则保护逻辑误判,整个系统可能就停了。

我们这个 legacy_app 是个运行在储能柜里的 App 层程序,用的是 Linux,通信走 SocketCAN。刚开始接手的时候,CAN 的读写代码其实已经能跑了,但我越读越觉得不对劲------它跟串口、TCP、UDP、WebSocket 这些通道的代码,风格不统一,而且散在各处。

真正让我下决心重构的,是一个很具体的问题:我们的储能柜有多个 CAN 口,可能同时接 BMS、接 PCS、接电表,甚至以后还要接别的设备。如果每个采样器都自己写一套 CAN 收发逻辑,那一旦 CAN 驱动要升级、要换内核接口,我就得满项目去找所有用到 CAN 的地方改。

所以我把所有通信通道统一抽象成了六个函数,让 CAN 只是其中一个"插件"。这篇文章就讲这件事------不是讲 CAN 协议本身的理论(那个网上很多),而是讲怎么把 CAN 变成一个可插拔的 HAL 层,以及在这个过程中踩过的坑。


一、HAL 六函数:所有通信通道的"公约数"

我先说清楚这个抽象长什么样。不管你是 CAN、串口、TCP 还是 WebSocket,在 App 层看来,你都只有六个动作:

c 复制代码
HANDLE HAL_CommOpen(char*, char*, DWORD, int, int*);   // 打开
HANDLE HAL_CommAccept(HANDLE);                         // 接受连接(TCP/WS 才有意义)
int    HAL_CommRead(HANDLE, char*, int);               // 读
int    HAL_CommWrite(HANDLE, char*, int);              // 写
int    HAL_CommControl(HANDLE, int, void*, int);       // 控制(设波特率、清缓存、设过滤等)
int    HAL_CommClose(HANDLE);                          // 关闭

这六个函数原型在 comm_port.h 里,是整个通信层的"宪法"。我的想法很朴素:上层业务代码永远只跟这六个函数打交道,永远不直接碰 CAN 的具体实现。

这个设计不是我想出来的,是参考了早年 InfyPower 那套通信框架的思路(项目里 comm_port.h 还留着那个年代的痕迹)。但好处是真的实打实的:上层的采样器、上报器,它们根本不知道自己读的数据是来自 CAN 还是来自串口。你给它们一个 handle,它们就 ReadWriteControl,剩下的事交给驱动层。

这里有个我觉得值得说清楚的点:为什么是这六个,而不是五个或七个?

因为通信的本质就三件事:建立连接(Open/Accept)、搬运数据(Read/Write)、管理状态(Control/Close)。这六个函数是所有通信通道的"最大公约数"。至于 CAN 特有的东西------比如设置波特率、设置接收过滤器、复位总线------全部塞进 Control 这个"万能接口"里,用不同的命令码(nCmd)来区分。

这个设计有个代价,就是 Control 会变成一个巨大的 switch。但我当时权衡过:与其为了 CAN 的特例去破坏六函数的一致性,不如让 CAN 的"特例"都关在 Control 的笼子里。事实也证明,这个代价是值得的。


二、CAN 驱动是怎么实现这六个函数的

现在看 CAN 这边。CAN 驱动在 comm_socketcan.c,它的头文件 comm_socketcan.h 里有一句非常关键的宏:

c 复制代码
#define _MAKE_SHARED_LIB

#ifdef _MAKE_SHARED_LIB
#define CAN_CommOpen        HAL_CommOpen
#define CAN_CommAccept      HAL_CommAccept
#define CAN_CommRead        HAL_CommRead
#define CAN_CommWrite       HAL_CommWrite
#define CAN_CommControl     HAL_CommControl
#define CAN_CommClose       HAL_CommClose
#endif

也就是说,CAN 驱动里写的 CAN_CommOpen,编译成动态库之后,导出的符号名就是 HAL_CommOpen。这样它就跟串口、TCP 那些驱动对外暴露一模一样的符号 ,上层加载器才能用同一套 dlsym 逻辑去解析。

我先讲 CAN_CommOpen,因为它是所有设计的入口,也是坑最多的地方。

打开一个 CAN 口:从 can1can0 的坑

看代码(我简化了日志和注释):

c 复制代码
HANDLE CAN_CommOpen(IN char *pPortDescriptor, IN char *pOpenParams,
                    IN DWORD dwPortAttr, IN int nTimeout, OUT int *pErrCode)
{
    CAN_PORT_DRV *pPort = NEW(CAN_PORT_DRV, 1);
    ...
    pPort->nSocket = socket(PF_CAN, SOCK_RAW, CAN_RAW);
    ...
    // 解析 can1/can2
    struct sockaddr_can addr;
    struct ifreq ifr;

    if(strcmp(pPortDescriptor, "can1") == 0) {
        strcpy(ifr.ifr_name, "can0");
    } else if(strcmp(pPortDescriptor, "can2") == 0) {
        strcpy(ifr.ifr_name, "can1");
    } else {
        *pErrCode = ERR_COMM_OPENING_PARAM;
        return NULL;
    }
    ...
}

这里有个我自己都笑了的坑:业务层的 can1 对应的是内核里的 can0can2 对应 can1

为什么?因为业务侧的命名习惯是从 1 开始编号(can1、can2 听着顺口),而 Linux SocketCAN 内核里,第一个 CAN 接口默认就是 can0,从 0 开始。这个"错位"第一次接手时把我整懵了------配置里明明写着 can1,结果去 /sys/class/net/ 一看根本没有 can1 这个接口,只有 can0。

所以这段代码里专门做了一层映射:业务层的 can1 → 内核 can0can2 → 内核 can1。这层映射很不起眼,但它是业务语义和底层现实之间的"翻译官"。很多嵌入式项目的"诡异 bug",本质都是这种命名错位没人说清楚。

设波特率:一个"看门狗"式的细节

接着是设置波特率,这段代码值得单独讲:

c 复制代码
    UINT nBaud = GetBaud_ByLevel(dwPortAttr);
    ...
    can_do_stop(ifr.ifr_name);
    can_set_bitrate(ifr.ifr_name, nBaud);
    can_do_start(ifr.ifr_name);

    UINT nRestartTimes = 0;
    can_get_restart_ms(ifr.ifr_name, &nRestartTimes);
    struct can_bittiming bitTime;
    can_get_bittiming(ifr.ifr_name, &bitTime);

这里用的是 libsocketcan 提供的封装(can_do_stopcan_set_bitratecan_do_start),而不是直接 ioctl。libsocketcan 是 SocketCAN 官方配套的用户态库,它把这些 SIOCSIF* 的 ioctl 细节封装起来了。

但真正有意思的是下面这个被注释掉的调试代码------它调用了 can_get_restart_ms 去读"总线重启了多少次":

c 复制代码
    // printf("---The restartTimes for %s is => %d ...\n", ifr.ifr_name, nRestartTimes, ...);

这个 restart_ms 是什么?它记录的是 CAN 控制器进入 Bus-Off(总线关闭)状态后自动恢复的次数。Bus-Off 是 CAN 协议里一个重要的错误状态:当发送错误计数超过 255,控制器会主动脱离总线,避免自己这个"捣乱的节点"拖垮整条总线。

我在调试阶段专门加了这个读取,是因为现场出现过一种情况:某个 CAN 口反复 Bus-Off,但上层采样器却"看起来一切正常"(因为数据偶尔还能读到)。后来靠这个 restart 计数,才确认是线缆接触不良导致的间歇性错误帧。这个调试代码虽然最后注释掉了,但它帮我定位了一个真实的现场问题。

一个 500 的队列长度:写给未来的自己

还有一处,是设置发送队列长度:

c 复制代码
    ifr.ifr_qlen = 500;
    if (ioctl(pPort->nSocket, SIOCSIFTXQLEN, &ifr) < 0) {
        printf("[Open CAN Socket] -- Set tx_queue_len fail: %s.\n", strerror(errno));
    }

SocketCAN 默认的发送队列长度是 10。这意味着如果你一次性 write 超过 10 帧,多出来的帧会被丢掉,write 会返回 ENOBUFS

我们储能系统在某些工况下需要连续快速下发一批 CAN 帧(比如批量查询电芯电压)。默认的 10 帧队列根本不够用,导致偶发的"数据写不出去"。所以我把队列长度调到了 500。

但这里有个我后来才意识到的更深的问题------调大队列只是治标 。如果上层真的持续以超过总线带宽的速度写帧,队列迟早还是会满。真正的解法应该是上层感知到 ENOBUFS 后主动降速或排队。这个我在后面讲 CAN_CommWrite 时会再展开。


三、读帧:一段让我找到"计数错位" bug 的代码

现在讲 CAN_CommRead。这是数据从内核到业务层的入口,也是我重构时发现第一个真 bug 的地方。

先看核心的读循环(简化):

c 复制代码
int CAN_CommRead(IN HANDLE hPort, OUT char *pBuffer, IN int nBytesToRead)
{
    ...
    while (nBytesToRead > 0) {
        memset(&r_frame, 0, sizeof(r_frame));
        rc = read(fd, &r_frame, sizeof(r_frame));  // 读一帧 can_frame

        if(rc == sizeof(r_frame)) {
            CAN_ParseReadFrameData(pBuffer, r_frame);  // 解析成业务格式
            nBytesToRead -= rc;
            pBuffer += rc;
            nTotalBytesRead += rc;
            ...
        }
    }
}

注意到一个关键点:读到的是一整个 struct can_frame(16 字节),但解析函数 CAN_ParseReadFrameData 把它转成了业务自定义的帧格式 ,长度只有 5 + 数据长度 字节。

看看解析函数:

c 复制代码
void CAN_ParseReadFrameData(OUT char *pBuffer, IN struct can_frame r_frame)
{
    BYTE byErrCode;
    byErrCode = (BYTE)(r_frame.can_id >> 29) & 0x07;

    if(byErrCode == 0x01) {  // 错误帧,跳过
        return;
    }

    pBuffer[0] = (BYTE)r_frame.can_dlc;              // 数据长度
    pBuffer[1] = (BYTE)((r_frame.can_id >> 24) & 0x1F); // 帧 ID 高位
    pBuffer[2] = (BYTE)(r_frame.can_id >> 16);
    pBuffer[3] = (BYTE)(r_frame.can_id >> 8);
    pBuffer[4] = (BYTE)r_frame.can_id;               // 帧 ID 低位

    r_frame.can_dlc = MIN(r_frame.can_dlc, 8);
    memcpy(&pBuffer[5], r_frame.data, r_frame.can_dlc);  // 数据
}

这里藏着那个"计数错位"的 bug:解析函数实际只写了 5 + can_dlc 字节(CAN 数据最多 8 字节,所以最多 13 字节),但读循环里却用 rc = sizeof(r_frame) = 16 去递增指针和计数:

c 复制代码
nBytesToRead -= rc;      // rc = 16
pBuffer += rc;           // 但实际只写了 13 字节!
nTotalBytesRead += rc;

这意味着什么? 当上层一次要求读多帧(nBytesToRead 大于一帧)时,指针会按 16 字节跳,但实际数据只写了 13 字节,中间会空出 3 字节的"黑洞"。连续读几帧之后,缓冲区的指针就彻底错位了。

这个 bug 在"一次只读一帧"的场景下完全不暴露------因为读一帧就 break 了。但一旦上层要求批量读,数据就会串位。这也是我后来下定决心重构通信层的原因之一:这种"平时不炸、特定工况才炸"的 bug,是最危险的一类。

还有一点值得说:错误帧的判断。CAN 标准里,帧 ID 的 bit 29~31 是标志位(EFF 扩展帧标志、RTR 远程帧标志、ERR 错误帧标志)。代码里 (r_frame.can_id >> 29) & 0x07 就是取出这三位,然后判断 == 0x01 表示这是一个错误帧,直接跳过不处理。

这个逻辑是对的,但写法太"魔数"了。SocketCAN 头文件里其实有现成的 CAN_ERR_FLAG0x20000000)、CAN_EFF_FLAGCAN_RTR_FLAG 这些宏。用宏的话,代码意图会清楚得多。我当时为了赶进度沿用了旧写法,现在回头看,这属于应该改但一直没排上期的"技术债"。


四、写帧:一次 Sleep_Ex(1) 暴露的背压问题

再看 CAN_CommWrite。这段代码里有一句让我纠结了很久的注释:

c 复制代码
    while (nBytesToWrite > 0) {
        memset(&w_frame, 0, sizeof(w_frame));
        CAN_ParseWriteFrame(&w_frame, pBuffer);  // 业务格式 → can_frame

        rc = write(fd, &w_frame, sizeof(w_frame));

        if (rc == sizeof(w_frame)) {
            nBytesToWrite -= rc;
            pBuffer += rc;
            nTotalBytesWritten += rc;
            ...
            //added by Jimmy as if no sleep, will not be able to send more than 10 frames
            Sleep_Ex(1);   // ← 关键:不加这个 sleep,发不出 10 帧以上
        }
    }

那句注释是当年维护的同事留下的("Jimmy"是那个同事的名字,代码里到处都是他的痕迹)。他发现的表象是:批量发帧时,如果不 sleep 1ms,发到第 10 帧就发不出去了。

这个表象的根因,就是前面说的 SocketCAN 默认发送队列长度只有 10 。你连续 write,前 10 帧进内核队列,第 11 帧时队列满了,write 返回 ENOBUFS。同事的解法是每发一帧 sleep 1ms,给内核队列一点"消化"时间。

这个解法能跑,但它有两个问题:

第一,sleep 1ms 是拍脑袋的数字。为什么是 1ms 不是 0.5ms 或 5ms?没有任何依据,只是"试出来能跑"。换一个更慢的总线波特率(比如 50K),1ms 可能又不够了。

第二,更根本的,它用"堵"的方式掩盖了"背压"问题 。正确的做法是:write 返回 ENOBUFS 时,说明发送队列满了,上层应该把这个错误传回去,让调用方决定是降速、排队还是丢弃,而不是自己偷偷 sleep 硬扛。

不过我后来想通了------在当时的约束下(没有 RTOS、就是裸的 Linux 进程、业务不允许丢帧),这个 Sleep_Ex(1) 其实是工程上可以接受的权宜之计 。嵌入式开发里,这种"不优雅但能跑"的方案大量存在。重要的是要理解它为什么能跑,以及它的边界在哪,而不是无脑抄或者无脑批判。


五、控制接口:把 CAN 的特例都关进 switch 里

CAN_CommControl 就是前面说的那个"万能接口"。看它的 switch 结构(我只挑几个关键的命令码讲):

c 复制代码
int CAN_CommControl(IN HANDLE hPort, IN int nCmd,
                    IN OUT void *pBuffer, IN int nDataLength)
{
    ...
    switch (nCmd) {
    case COMM_GET_CAN_BAUDRATE:
        can_get_bittiming(pPort->szCanPort, &btt);
        *(UINT *)pBuffer = (UINT)btt.bitrate;
        break;

    case COMM_SET_CAN_BAUDRATE:
        UINT nBaud = GetBaud_ByLevel(*(DWORD *)pBuffer);
        can_do_stop(pPort->szCanPort);
        can_set_bitrate(pPort->szCanPort, nBaud);
        can_do_start(pPort->szCanPort);
        break;

    case COMM_SET_CAN_FILTER:
        setsockopt(pPort->nSocket, SOL_CAN_RAW, CAN_RAW_FILTER,
                   (struct can_filter*)pBuffer, nFilterSize);
        break;

    case COMM_RESET_CAN:
        can_do_restart(pPort->szCanPort);
        break;
    }
}

这里能看到 CAN 特有的四个操作------查波特率、设波特率、设接收过滤、复位总线------全都以命令码的形式塞进了 Control。上层如果想给 CAN 口设个 250K 的波特率,就调 HAL_CommControl(handle, COMM_SET_CAN_BAUDRATE, &baud, sizeof(baud)),不需要知道底层是 can_set_bitrate 还是别的什么。

接收过滤器(CAN_RAW_FILTER)是 CAN 性能的关键。 CAN 总线是广播式的,一条总线上所有节点都能看到所有帧。如果不设过滤器,你的用户态进程会被总线上的所有帧"轰炸",每一帧都要从内核拷贝到用户态,再被业务代码丢掉------这是巨大的 CPU 浪费,尤其在帧率高的储能工况下。

所以 COMM_SET_CAN_FILTER 让上层能按 CAN ID + 掩码来过滤,只接收自己关心的帧。这是 SocketCAN 提供的硬件加速过滤能力(CAN_RAW_FILTER),在内核里就过滤掉了,不占用户态 CPU。这个功能看起来不起眼,但对储能这种"长时间、高帧率、多路 CAN"的场景,省下来的 CPU 很可观。


六、动态加载:把"插件化"真正落地的最后一公里

前面讲的都是 CAN 驱动"怎么实现六个函数"。但光实现还不够------上层怎么找到并加载这个 CAN 驱动? 这就是 comm_loader.c 干的事。

核心是这个函数:

c 复制代码
static BOOL Load_ACommPort(COMMPORT* pCommPort)
{
    static const char *pszSymName[] = {
        SYM_HAL_COMMOPEN,    // "HAL_CommOpen"
        SYM_HAL_COMMACCEPT,  // "HAL_CommAccept"
        SYM_HAL_COMMREAD,    // "HAL_CommRead"
        SYM_HAL_COMMWRITE,   // "HAL_CommWrite"
        SYM_HAL_COMMCONTROL, // "HAL_CommControl"
        SYM_HAL_COMMCLOSE    // "HAL_CommClose"
    };

    HANDLE *pfnProc[] = {
        (HANDLE *)&pCommPort->Open,
        (HANDLE *)&pCommPort->Accept,
        (HANDLE *)&pCommPort->Read,
        (HANDLE *)&pCommPort->Write,
        (HANDLE *)&pCommPort->Control,
        (HANDLE *)&pCommPort->Close
    };

    pCommPort->hLib = LoadDynamicLibrary(
        MakeFullFileName(szFullLibPath, getenv(ENV_VARIABLE_APP_PATH),
                         "comm_port", pCommPort->info.szPortDriver),
        ITEM_OF(pszSymName), pszSymName, pfnProc, TRUE);
    ...
}

这个函数做了一件非常优雅的事:它有一个"符号名数组"和一个"函数指针数组",两个数组一一对应,然后用一个循环把动态库里的六个符号,逐个填进 COMMPORT 结构体的六个函数指针里。

而真正的 dlopen + dlsym 逻辑在 LoadDynamicLibrary 里(iBasic_Funs.c):

c 复制代码
HANDLE LoadDynamicLibrary(IN const char *pszLibName, IN int nSym,
                          IN const char *ppszSymName[], OUT HANDLE *pfnProc[],
                          IN BOOL bNeedAllSym)
{
    HANDLE hLib = (HANDLE)dlopen(pszLibName, RTLD_LAZY);
    if (hLib == NULL) {
        const char *err = dlerror();
        ...
        return NULL;
    }

    for (i = 0; i < nSym; i++) {
        *pfnProc[i] = (HANDLE)dlsym(hLib, ppszSymName[i]);
        if (*pfnProc[i] == NULL) {
            if (bNeedAllSym) {   // 所有符号都必须找到
                dlclose(hLib);
                return NULL;
            }
        }
    }
    return hLib;
}

到这里,整个"插件化"的闭环就完整了:

  1. 每个通信驱动 (CAN、串口、TCP、WebSocket......)都编译成一个 .so 动态库,导出统一的 HAL_Comm* 六个符号。
  2. 上层加载器comm_loader.c)启动时读配置,拿到每个端口的驱动名(比如 comm_socketcan.so),用 dlopen 打开。
  3. dlsym 逐个解析 六个 HAL_Comm* 符号,填进 COMMPORT 结构体的函数指针表。
  4. 业务代码 只通过 COMMPORT 结构体里的函数指针调用,完全不知道、也不关心底层是哪个驱动。

这就是经典的**"面向接口编程"在 C 语言里的落地**。C 语言没有类的多态,但函数指针表 + 动态加载,同样能达到"新增一个通信驱动,不用改任何上层代码"的效果。

这套机制给我带来的实际好处

我举一个真实的例子。我们项目里通信通道有 9 种:CAN、串口、TCP、UDP、WebSocket、IPC、HTTP/FTP、RPMSG...... 后来需求加了一个新的 comm_websocket_ex(用 noPoll 而不是 libwebsockets 的另一个 WebSocket 实现)。

如果当时没有这层抽象,加一个 WebSocket 变体,我得去改上层所有用到 WebSocket 的采样器和上报器。但有了这层抽象,我只需要:

  1. 写一个新的 comm_websocket_ex 驱动,实现六个 HAL_Comm* 函数;
  2. 编译成 .so,放到 /app/comm_port/ 目录;
  3. 在配置里把这个端口指向新驱动。

上层一行代码都不用改。这就是抽象层最实在的价值------它把"变化"关在了一个局部范围里。


七、这套设计里的真实技术债(诚实复盘)

我不想过誉自己这套东西。诚实地讲,这个 CAN HAL 层有几个我明知道、但一直没排上期去修的债:

1. 错误路径的内存泄漏。 CAN_CommOpen 里,NEW(CAN_PORT_DRV, 1) 分配了内存后,好几条错误路径直接 return NULL,没释放 pPort。因为 CAN_CommOpen 失败后设备基本也就起不来了(CAN 打不开是致命错误),所以泄漏的危害被掩盖了。但严格来说这是不干净的。

2. 前面说的"计数错位" bugCAN_CommRead 里用 16 递增但只写 13 字节)。这个是最该修的。

3. 魔数判断错误帧(can_id >> 29) & 0x07 应该用 CAN_ERR_FLAG 宏)。

4. GBK 编码的中文注释。 这批老代码的中文注释是 GBK 编码,很多现代工具(包括我后来想用的 codebase-memory 这种代码图谱工具)直接读不了,被当成非 UTF-8 文件跳过了。这也逼得我后来只能靠 grep + 人肉读源码。

5. 大量被注释掉的调试代码。 文件里到处是 // Jimmy added...// printf(...) 这种注释块。历史信息有保留价值,但太多了就成了噪音。

这些债,有的是历史遗留(接手时就有的),有的是我当时为了赶进度埋下的。它们不影响系统跑起来,但确实影响可维护性------尤其是当我想把这个项目往开源方向整理的时候,这些债就变成了必须先清理的拦路虎。


八、总结:我从这个 CAN 抽象层学到的东西

写到这里,我想把这次的收获浓缩成几条,给同样在做嵌入式通信层的朋友:

第一,抽象的价值不在"优雅",在"隔离变化"。 六函数的 HAL 层,代码本身不复杂,真正值钱的是它把"通信驱动的变化"关在了一个局部范围。新增、替换一个驱动,上层无感。这在嵌入式项目里,省下的不只是写代码的时间,更是排查"到底改哪几处"的心力。

第二,命名错位是嵌入式项目的高频坑。 业务层叫 can1、内核叫 can0,这种"从 1 还是从 0 开始"的错位,比任何算法 bug 都更隐蔽、更难查。文档里写清楚映射关系,比多写十行代码重要。

第三,理解"权宜之计"的边界。 Sleep_Ex(1) 这种方案,工程上可以用,但你要清楚它为什么能跑、它的边界在哪。它是在"没有更好的背压机制"下的妥协,不是正确答案。一旦条件允许(比如上了 RTOS 或重写了发送队列管理),就该换掉它。

第四,诚实地面对技术债。 每个长期运行的项目都有债。关键不是零债(不现实),而是知道自己有哪些债、影响面多大、什么时候该还。我列出的那五条债,就是我对这个模块的"欠账清单"。


这个 CAN HAL 层,从六函数抽象,到 SocketCAN 的具体实现,再到 dlopen/dlsym 的动态加载,最后到那些真实的技术债,我尽量把"为什么这么设计"和"哪里做得不好"都说清楚了。储能这个行业,代码不见得多高深,但"在资源受限、可靠性要求高的环境里,把一层抽象做好做稳",本身就是件挺有技术含量的事。

如果你也在做类似的嵌入式通信层,希望这篇复盘对你有用。有什么想深入讨论的------比如 SocketCAN 的错误处理、CAN FD 的适配、或者函数指针表这套模式的更多细节------欢迎来找我聊。

相关推荐
江畔柳前堤1 小时前
AgentScope 设计与原理全解:从消息原语到分布式智能体工程底座
大数据·人工智能·分布式·目标检测·机器学习·语言模型·架构
Yiran_G1 小时前
低功耗 Zigbee 模组怎么选?基于 CC2340R53 的 WS8823 设计实践
人工智能·物联网·智能家居
Elastic 中国社区官方博客1 小时前
跳过有状态的 OTel Collector:Elasticsearch 9.5 原生存储两种指标时间类型
大数据·人工智能·elasticsearch·搜索引擎·重构·全文检索
show4331 小时前
2026小程序端AI智能优化技术实践:转文字后自动纠错+润色+分段算法分析
人工智能·算法·小程序
今天AI了吗1 小时前
Agent & AI 名词大扫盲
数据库·人工智能·python·sql·rust
iaku1 小时前
Prompt 不是玄学:写给前端的 Prompt 工程指南
前端·人工智能
武子康1 小时前
从 Pi 学习设计自己的 Agent Harness:一条可验证的垂直生产线
人工智能·llm·agent
HAHAXX81 小时前
从传统RPA到AI增强RPA:内网离线部署、元素自愈与Prompt工程落地实践
人工智能·prompt·rpa
两万五千个小时1 小时前
DeepSeek Harness 上下文压缩:长对话管理
人工智能·程序员·架构