OPC UA 协议栈 C 语言实现

OPC UA协议栈开源项目分析和核心代码实现

一、开源 OPC UA 协议栈选择

1.1 主要开源实现对比

项目 语言 许可证 特点 活跃度
open62541 C/C++ MPL-2.0 最完整,工业级 ★★★★★
FreeOpcUa C++/Python LGPL 功能丰富,Python绑定 ★★★★☆
UA-.NET C# MIT .NET平台,微软支持 ★★★★☆
libopcua C LGPL 轻量级,适合嵌入式 ★★★☆☆

推荐open62541 - 最成熟的开源实现,符合OPC UA Part 4-6标准。

二、open62541 核心架构

2.1 项目结构

复制代码
open62541/
├── src/                    # 核心源代码
│   ├── client/            # 客户端实现
│   ├── server/            # 服务器实现
│   ├── plugin/            # 插件系统
│   ├── core/              # 核心模块
│   │   ├── types.h        # 数据类型定义
│   │   ├── message.h      # 消息编码
│   │   ├── transport.h    # 传输层
│   │   ├── security.h     # 安全模块
│   │   └── subscription.h # 订阅管理
├── examples/              # 示例代码
├── tests/                 # 测试代码
└── plugins/               # 可选插件

三、核心模块 C 语言实现

3.1 基本数据类型定义

c 复制代码
/**
 * @file opcua_types.h
 * @brief OPC UA 基本数据类型定义
 */

#ifndef OPCUA_TYPES_H
#define OPCUA_TYPES_H

#include <stdint.h>
#include <stdbool.h>

/* 基本数据类型 */
typedef uint8_t  UA_Byte;
typedef int8_t   UA_SByte;
typedef uint16_t UA_UInt16;
typedef int16_t  UA_Int16;
typedef uint32_t UA_UInt32;
typedef int32_t  UA_Int32;
typedef uint64_t UA_UInt64;
typedef int64_t  UA_Int64;
typedef float    UA_Float;
typedef double   UA_Double;
typedef bool     UA_Boolean;
typedef char*    UA_String;

/* 扩展数据类型 */
typedef struct {
    UA_UInt32 length;
    UA_Byte *data;
} UA_ByteString;

typedef struct {
    UA_UInt32 length;
    UA_UInt16 *data;
} UA_String;

/* 节点ID类型 */
typedef enum {
    UA_NODEIDTYPE_NUMERIC = 0,
    UA_NODEIDTYPE_STRING  = 1,
    UA_NODEIDTYPE_GUID    = 2,
    UA_NODEIDTYPE_BYTESTRING = 3
} UA_NodeIdType;

typedef struct {
    UA_NodeIdType identifierType;
    UA_UInt16 namespaceIndex;
    union {
        UA_UInt32 numeric;
        UA_String string;
        UA_ByteString byteString;
        /* GUID 省略简化 */
    } identifier;
} UA_NodeId;

/* 扩展对象节点ID */
#define UA_NODEID_NUMERIC(ns, id) {UA_NODEIDTYPE_NUMERIC, ns, {.numeric = id}}
#define UA_NODEID_STRING(ns, str) {UA_NODEIDTYPE_STRING, ns, {.string = str}}

/* 变体类型 */
typedef enum {
    UA_VARIANT_DATA_NULL = 0,
    UA_VARIANT_DATA_BOOLEAN,
    UA_VARIANT_DATA_SBYTE,
    UA_VARIANT_DATA_BYTE,
    UA_VARIANT_DATA_INT16,
    UA_VARIANT_DATA_UINT16,
    UA_VARIANT_DATA_INT32,
    UA_VARIANT_DATA_UINT32,
    UA_VARIANT_DATA_INT64,
    UA_VARIANT_DATA_UINT64,
    UA_VARIANT_DATA_FLOAT,
    UA_VARIANT_DATA_DOUBLE,
    UA_VARIANT_DATA_STRING,
    UA_VARIANT_DATA_DATETIME,
    UA_VARIANT_DATA_GUID,
    UA_VARIANT_DATA_BYTESTRING,
    UA_VARIANT_DATA_ARRAY
} UA_VariantType;

typedef struct {
    UA_VariantType type;
    union {
        UA_Boolean boolean;
        UA_SByte   sbyte;
        UA_Byte    byte;
        UA_Int16   int16;
        UA_UInt16  uint16;
        UA_Int32   int32;
        UA_UInt32  uint32;
        UA_Int64   int64;
        UA_UInt64  uint64;
        UA_Float   floatVal;
        UA_Double  doubleVal;
        UA_String  string;
        UA_ByteString byteString;
        struct {
            void *data;
            UA_UInt32 size;
        } array;
    } data;
} UA_Variant;

/* 数据值结构 */
typedef struct {
    UA_Variant value;
    UA_Byte status;
    UA_UInt64 sourceTimestamp;
    UA_UInt16 sourcePicoseconds;
    UA_UInt64 serverTimestamp;
    UA_UInt16 serverPicoseconds;
} UA_DataValue;

#endif /* OPCUA_TYPES_H */

3.2 消息编码/解码

c 复制代码
/**
 * @file opcua_binary.h
 * @brief OPC UA 二进制编码实现
 */

#include "opcua_types.h"
#include <string.h>

/* 消息头定义 */
typedef struct {
    UA_UInt32 messageType;      // 消息类型 "HEL" "ACK" "ERR" "MSG"
    UA_UInt32 messageSize;      // 包括头部的总大小
    UA_UInt32 chunkType;        // 分块类型
} UA_MessageHeader;

/* 编码上下文 */
typedef struct {
    UA_Byte *buffer;
    UA_UInt32 position;
    UA_UInt32 capacity;
} UA_Encoder;

typedef struct {
    const UA_Byte *buffer;
    UA_UInt32 position;
    UA_UInt32 length;
} UA_Decoder;

/* 编码函数 */
UA_StatusCode UA_Encoder_init(UA_Encoder *enc, UA_Byte *buffer, UA_UInt32 capacity) {
    if(!buffer || capacity == 0)
        return UA_STATUSCODE_BADINTERNALERROR;
    
    enc->buffer = buffer;
    enc->position = 0;
    enc->capacity = capacity;
    
    return UA_STATUSCODE_GOOD;
}

/* 编码基本类型 */
void UA_Encoder_writeByte(UA_Encoder *enc, UA_Byte value) {
    if(enc->position < enc->capacity) {
        enc->buffer[enc->position++] = value;
    }
}

void UA_Encoder_writeUInt16(UA_Encoder *enc, UA_UInt16 value) {
    UA_Encoder_writeByte(enc, (UA_Byte)(value & 0xFF));
    UA_Encoder_writeByte(enc, (UA_Byte)((value >> 8) & 0xFF));
}

void UA_Encoder_writeUInt32(UA_Encoder *enc, UA_UInt32 value) {
    UA_Encoder_writeByte(enc, (UA_Byte)(value & 0xFF));
    UA_Encoder_writeByte(enc, (UA_Byte)((value >> 8) & 0xFF));
    UA_Encoder_writeByte(enc, (UA_Byte)((value >> 16) & 0xFF));
    UA_Encoder_writeByte(enc, (UA_Byte)((value >> 24) & 0xFF));
}

void UA_Encoder_writeInt32(UA_Encoder *enc, UA_Int32 value) {
    UA_Encoder_writeUInt32(enc, (UA_UInt32)value);
}

/* 编码字符串 */
void UA_Encoder_writeString(UA_Encoder *enc, const UA_String *str) {
    if(!str || str->length == 0) {
        UA_Encoder_writeUInt32(enc, 0xFFFFFFFF); // 空字符串
        return;
    }
    
    UA_Encoder_writeUInt32(enc, str->length);
    for(UA_UInt32 i = 0; i < str->length; i++) {
        UA_Encoder_writeByte(enc, (UA_Byte)str->data[i]);
    }
}

/* 编码节点ID */
void UA_Encoder_writeNodeId(UA_Encoder *enc, const UA_NodeId *nodeId) {
    UA_Encoder_writeByte(enc, (UA_Byte)nodeId->identifierType);
    UA_Encoder_writeUInt16(enc, nodeId->namespaceIndex);
    
    switch(nodeId->identifierType) {
        case UA_NODEIDTYPE_NUMERIC:
            UA_Encoder_writeUInt32(enc, nodeId->identifier.numeric);
            break;
            
        case UA_NODEIDTYPE_STRING:
            UA_Encoder_writeString(enc, &nodeId->identifier.string);
            break;
            
        case UA_NODEIDTYPE_BYTESTRING:
            UA_Encoder_writeUInt32(enc, nodeId->identifier.byteString.length);
            for(UA_UInt32 i = 0; i < nodeId->identifier.byteString.length; i++) {
                UA_Encoder_writeByte(enc, nodeId->identifier.byteString.data[i]);
            }
            break;
            
        default:
            // GUID类型简化处理
            break;
    }
}

/* 解码函数 */
UA_StatusCode UA_Decoder_init(UA_Decoder *dec, const UA_Byte *buffer, UA_UInt32 length) {
    if(!buffer || length == 0)
        return UA_STATUSCODE_BADINTERNALERROR;
    
    dec->buffer = buffer;
    dec->position = 0;
    dec->length = length;
    
    return UA_STATUSCODE_GOOD;
}

UA_Byte UA_Decoder_readByte(UA_Decoder *dec) {
    if(dec->position < dec->length) {
        return dec->buffer[dec->position++];
    }
    return 0;
}

UA_UInt16 UA_Decoder_readUInt16(UA_Decoder *dec) {
    UA_UInt16 value = UA_Decoder_readByte(dec);
    value |= (UA_UInt16)UA_Decoder_readByte(dec) << 8;
    return value;
}

UA_UInt32 UA_Decoder_readUInt32(UA_Decoder *dec) {
    UA_UInt32 value = UA_Decoder_readByte(dec);
    value |= (UA_UInt32)UA_Decoder_readByte(dec) << 8;
    value |= (UA_UInt32)UA_Decoder_readByte(dec) << 16;
    value |= (UA_UInt32)UA_Decoder_readByte(dec) << 24;
    return value;
}

UA_StatusCode UA_Decoder_readString(UA_Decoder *dec, UA_String *str) {
    UA_UInt32 length = UA_Decoder_readUInt32(dec);
    
    if(length == 0xFFFFFFFF) {
        str->length = 0;
        str->data = NULL;
        return UA_STATUSCODE_GOOD;
    }
    
    if(dec->position + length > dec->length) {
        return UA_STATUSCODE_BADDECODINGERROR;
    }
    
    str->length = length;
    str->data = (UA_UInt16*)malloc(length * sizeof(UA_UInt16));
    
    for(UA_UInt32 i = 0; i < length; i++) {
        str->data[i] = UA_Decoder_readByte(dec);
    }
    
    return UA_STATUSCODE_GOOD;
}

3.3 服务器核心实现

c 复制代码
/**
 * @file opcua_server.h
 * @brief OPC UA 服务器核心实现
 */

#ifndef OPCUA_SERVER_H
#define OPCUA_SERVER_H

#include "opcua_types.h"
#include "opcua_binary.h"

/* 服务器配置 */
typedef struct {
    UA_UInt16 port;
    UA_UInt32 maxConnections;
    UA_UInt32 maxWorkerThreads;
    UA_Boolean enableSecurity;
    UA_String applicationUri;
    UA_String productUri;
    UA_String applicationName;
} UA_ServerConfig;

/* 节点属性 */
typedef struct {
    UA_NodeId nodeId;
    UA_NodeClass nodeClass;
    UA_QualifiedName browseName;
    UA_LocalizedText displayName;
    UA_LocalizedText description;
    UA_UInt32 writeMask;
    UA_UInt32 userWriteMask;
} UA_NodeAttributes;

/* 服务器会话 */
typedef struct {
    UA_UInt32 sessionId;
    UA_String sessionName;
    UA_UInt32 authenticationToken;
    UA_UInt64 timeout;
    UA_UInt64 validTill;
    void *userContext;
} UA_Session;

/* 服务器结构 */
typedef struct {
    UA_ServerConfig config;
    
    /* 连接管理 */
    struct {
        int listenSocket;
        UA_UInt32 connectionCount;
        void **connections;
    } network;
    
    /* 节点管理 */
    struct {
        UA_UInt32 nodeCount;
        UA_NodeAttributes **nodes;
    } addressSpace;
    
    /* 会话管理 */
    struct {
        UA_UInt32 sessionCount;
        UA_Session **sessions;
    } sessionManager;
    
    /* 订阅管理 */
    struct {
        UA_UInt32 subscriptionCount;
        void **subscriptions;
    } subscriptionManager;
    
    /* 回调函数 */
    struct {
        void (*onRead)(UA_NodeId nodeId, UA_Variant *value);
        void (*onWrite)(UA_NodeId nodeId, UA_Variant *value);
        void (*onCall)(UA_NodeId methodId, UA_Variant *input, UA_Variant *output);
    } callbacks;
} UA_Server;

/* 服务器API */
UA_Server* UA_Server_new(const UA_ServerConfig *config);
UA_StatusCode UA_Server_run(UA_Server *server);
UA_StatusCode UA_Server_stop(UA_Server *server);
void UA_Server_delete(UA_Server *server);

/* 节点管理 */
UA_StatusCode UA_Server_addVariableNode(
    UA_Server *server,
    const UA_NodeId *parentNodeId,
    const UA_NodeId *referenceTypeId,
    const UA_QualifiedName *browseName,
    const UA_NodeId *typeDefinition,
    const UA_VariableAttributes *attr,
    void *nodeContext,
    UA_NodeId *outNodeId
);

/* 读写操作 */
UA_StatusCode UA_Server_readValue(
    UA_Server *server,
    const UA_NodeId *nodeId,
    UA_Variant *value
);

UA_StatusCode UA_Server_writeValue(
    UA_Server *server,
    const UA_NodeId *nodeId,
    const UA_Variant *value
);

#endif /* OPCUA_SERVER_H */

3.4 服务器实现文件

c 复制代码
/**
 * @file opcua_server.c
 * @brief OPC UA 服务器实现
 */

#include "opcua_server.h"
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>

/* 创建新服务器 */
UA_Server* UA_Server_new(const UA_ServerConfig *config) {
    UA_Server *server = (UA_Server*)calloc(1, sizeof(UA_Server));
    if(!server) return NULL;
    
    memcpy(&server->config, config, sizeof(UA_ServerConfig));
    
    /* 初始化地址空间 */
    server->addressSpace.nodes = (UA_NodeAttributes**)calloc(100, sizeof(UA_NodeAttributes*));
    server->addressSpace.nodeCount = 0;
    
    /* 初始化会话管理器 */
    server->sessionManager.sessions = (UA_Session**)calloc(10, sizeof(UA_Session*));
    server->sessionManager.sessionCount = 0;
    
    /* 创建监听socket */
    server->network.listenSocket = socket(AF_INET, SOCK_STREAM, 0);
    if(server->network.listenSocket < 0) {
        free(server);
        return NULL;
    }
    
    int opt = 1;
    setsockopt(server->network.listenSocket, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    struct sockaddr_in address;
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(config->port);
    
    if(bind(server->network.listenSocket, (struct sockaddr*)&address, sizeof(address)) < 0) {
        close(server->network.listenSocket);
        free(server);
        return NULL;
    }
    
    listen(server->network.listenSocket, 5);
    
    return server;
}

/* 运行服务器 */
UA_StatusCode UA_Server_run(UA_Server *server) {
    if(!server) return UA_STATUSCODE_BADINTERNALERROR;
    
    printf("OPC UA Server starting on port %d\n", server->config.port);
    
    socklen_t addrlen = sizeof(struct sockaddr_in);
    
    while(1) {
        struct sockaddr_in client_addr;
        int client_socket = accept(server->network.listenSocket, 
                                  (struct sockaddr*)&client_addr, &addrlen);
        
        if(client_socket < 0) {
            continue;
        }
        
        printf("New connection from %s:%d\n", 
               inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
        
        /* 创建新线程处理连接 */
        pthread_t thread;
        UA_ConnectionContext *ctx = (UA_ConnectionContext*)malloc(sizeof(UA_ConnectionContext));
        ctx->server = server;
        ctx->socket = client_socket;
        ctx->client_addr = client_addr;
        
        pthread_create(&thread, NULL, connection_handler, ctx);
        pthread_detach(thread);
    }
    
    return UA_STATUSCODE_GOOD;
}

/* 连接处理线程 */
void* connection_handler(void *arg) {
    UA_ConnectionContext *ctx = (UA_ConnectionContext*)arg;
    UA_Server *server = ctx->server;
    int client_socket = ctx->socket;
    
    UA_Byte buffer[4096];
    ssize_t bytes_received;
    
    while((bytes_received = recv(client_socket, buffer, sizeof(buffer), 0)) > 0) {
        /* 解码消息头 */
        UA_MessageHeader header;
        UA_Decoder decoder;
        
        UA_Decoder_init(&decoder, buffer, bytes_received);
        
        /* 解析消息类型 */
        header.messageType = UA_Decoder_readUInt32(&decoder);
        header.messageSize = UA_Decoder_readUInt32(&decoder);
        header.chunkType = UA_Decoder_readUInt32(&decoder);
        
        /* 处理不同类型的消息 */
        if(header.messageType == 0x4D534748) { // "HEL"
            handle_hello_message(server, client_socket, &decoder);
        } else if(header.messageType == 0x4D534747) { // "MSG"
            handle_request_message(server, client_socket, &decoder);
        }
    }
    
    close(client_socket);
    free(ctx);
    return NULL;
}

/* 处理Hello消息 */
void handle_hello_message(UA_Server *server, int socket, UA_Decoder *decoder) {
    /* 读取Hello消息内容 */
    UA_UInt32 protocolVersion = UA_Decoder_readUInt32(decoder);
    UA_UInt32 receiveBufferSize = UA_Decoder_readUInt32(decoder);
    UA_UInt32 sendBufferSize = UA_Decoder_readUInt32(decoder);
    UA_UInt32 maxMessageSize = UA_Decoder_readUInt32(decoder);
    UA_UInt32 maxChunkCount = UA_Decoder_readUInt32(decoder);
    
    printf("Received Hello: ProtocolVersion=%u, ReceiveBuffer=%u, SendBuffer=%u\n",
           protocolVersion, receiveBufferSize, sendBufferSize);
    
    /* 发送Acknowledge消息 */
    UA_Byte response[28] = {0};
    UA_Encoder encoder;
    UA_Encoder_init(&encoder, response, sizeof(response));
    
    /* 消息头 */
    UA_Encoder_writeUInt32(&encoder, 0x41434B47); // "ACK"
    UA_Encoder_writeUInt32(&encoder, 28);         // 消息大小
    UA_Encoder_writeUInt32(&encoder, 0);          // 分块类型
    
    /* 消息体 */
    UA_Encoder_writeUInt32(&encoder, 0);          // 协议版本
    UA_Encoder_writeUInt32(&encoder, 8192);       // 接收缓冲区大小
    UA_Encoder_writeUInt32(&encoder, 8192);       // 发送缓冲区大小
    UA_Encoder_writeUInt32(&encoder, 65536);      // 最大消息大小
    UA_Encoder_writeUInt32(&encoder, 0);          // 最大分块数
    
    send(socket, response, 28, 0);
}

/* 处理请求消息 */
void handle_request_message(UA_Server *server, int socket, UA_Decoder *decoder) {
    /* 读取请求类型 */
    UA_UInt32 requestType = UA_Decoder_readUInt32(decoder);
    
    switch(requestType) {
        case 0x4D534743:  // "C" - CreateSession
            handle_create_session(server, socket, decoder);
            break;
            
        case 0x4D534741:  // "A" - ActivateSession
            handle_activate_session(server, socket, decoder);
            break;
            
        case 0x4D534752:  // "R" - Read
            handle_read_request(server, socket, decoder);
            break;
            
        case 0x4D534757:  // "W" - Write
            handle_write_request(server, socket, decoder);
            break;
            
        case 0x4D534742:  // "B" - Browse
            handle_browse_request(server, socket, decoder);
            break;
            
        default:
            printf("Unknown request type: 0x%08X\n", requestType);
            break;
    }
}

/* 处理读取请求 */
void handle_read_request(UA_Server *server, int socket, UA_Decoder *decoder) {
    /* 解码读取参数 */
    UA_Decoder_readUInt32(decoder);  // RequestId
    UA_Decoder_readUInt32(decoder);  // TimestampsToReturn
    
    UA_UInt32 nodesToReadCount = UA_Decoder_readUInt32(decoder);
    
    UA_NodeId *nodes = (UA_NodeId*)malloc(nodesToReadCount * sizeof(UA_NodeId));
    for(UA_UInt32 i = 0; i < nodesToReadCount; i++) {
        UA_Decoder_readNodeId(decoder, &nodes[i]);
    }
    
    /* 准备响应 */
    UA_Byte response[1024];
    UA_Encoder encoder;
    UA_Encoder_init(&encoder, response, sizeof(response));
    
    /* 消息头 */
    UA_Encoder_writeUInt32(&encoder, 0x4D534747);  // "MSG"
    UA_Encoder_writeUInt32(&encoder, 0);           // 临时大小
    UA_Encoder_writeUInt32(&encoder, 0);           // 分块类型
    
    /* 响应体 */
    UA_Encoder_writeUInt32(&encoder, 0x4D534752);  // "R" - ReadResponse
    UA_Encoder_writeUInt32(&encoder, 1);           // RequestId
    
    /* 结果数组 */
    UA_Encoder_writeUInt32(&encoder, nodesToReadCount);
    
    for(UA_UInt32 i = 0; i < nodesToReadCount; i++) {
        UA_Variant value;
        UA_StatusCode status = UA_Server_readValue(server, &nodes[i], &value);
        
        /* 写入状态码 */
        UA_Encoder_writeUInt32(&encoder, status);
        
        if(status == UA_STATUSCODE_GOOD) {
            /* 写入数据值 */
            UA_Encoder_writeVariant(&encoder, &value);
        }
    }
    
    /* 更新消息大小 */
    UA_UInt32 messageSize = encoder.position;
    memcpy(&response[4], &messageSize, 4);
    
    send(socket, response, messageSize, 0);
    
    free(nodes);
}

3.5 客户端实现

c 复制代码
/**
 * @file opcua_client.h
 * @brief OPC UA 客户端实现
 */

#ifndef OPCUA_CLIENT_H
#define OPCUA_CLIENT_H

#include "opcua_types.h"

typedef struct {
    int socket;
    char *endpointUrl;
    UA_UInt32 requestId;
    UA_Session *session;
} UA_Client;

/* 客户端API */
UA_Client* UA_Client_new(void);
UA_StatusCode UA_Client_connect(UA_Client *client, const char *endpointUrl);
UA_StatusCode UA_Client_disconnect(UA_Client *client);
void UA_Client_delete(UA_Client *client);

/* 服务调用 */
UA_StatusCode UA_Client_read(
    UA_Client *client,
    const UA_NodeId *nodeId,
    UA_Variant *value
);

UA_StatusCode UA_Client_write(
    UA_Client *client,
    const UA_NodeId *nodeId,
    const UA_Variant *value
);

UA_StatusCode UA_Client_browse(
    UA_Client *client,
    const UA_NodeId *nodeId,
    UA_BrowseResult *result
);

UA_StatusCode UA_Client_call(
    UA_Client *client,
    const UA_NodeId *objectId,
    const UA_NodeId *methodId,
    const UA_Variant *inputArguments,
    size_t inputArgumentsSize,
    UA_Variant *outputArguments,
    size_t *outputArgumentsSize
);

#endif /* OPCUA_CLIENT_H */
c 复制代码
/**
 * @file opcua_client.c
 * @brief OPC UA 客户端实现
 */

#include "opcua_client.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>

/* 创建新客户端 */
UA_Client* UA_Client_new(void) {
    UA_Client *client = (UA_Client*)calloc(1, sizeof(UA_Client));
    if(!client) return NULL;
    
    client->socket = -1;
    client->requestId = 1;
    client->session = NULL;
    
    return client;
}

/* 连接到服务器 */
UA_StatusCode UA_Client_connect(UA_Client *client, const char *endpointUrl) {
    if(!client || !endpointUrl) {
        return UA_STATUSCODE_BADINTERNALERROR;
    }
    
    /* 解析URL */
    char hostname[256];
    int port = 4840;  // 默认端口
    
    if(sscanf(endpointUrl, "opc.tcp://%255[^:]:%d", hostname, &port) < 1) {
        strcpy(hostname, endpointUrl);
    }
    
    /* 创建socket */
    client->socket = socket(AF_INET, SOCK_STREAM, 0);
    if(client->socket < 0) {
        return UA_STATUSCODE_BADCOMMUNICATIONERROR;
    }
    
    /* 解析主机名 */
    struct hostent *server = gethostbyname(hostname);
    if(!server) {
        close(client->socket);
        return UA_STATUSCODE_BADHOSTUNKNOWN;
    }
    
    /* 连接服务器 */
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    memcpy(&server_addr.sin_addr.s_addr, server->h_addr, server->h_length);
    server_addr.sin_port = htons(port);
    
    if(connect(client->socket, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        close(client->socket);
        return UA_STATUSCODE_BADCONNECTIONCLOSED;
    }
    
    client->endpointUrl = strdup(endpointUrl);
    
    /* 发送Hello消息 */
    UA_Byte hello[28] = {0};
    UA_Encoder encoder;
    UA_Encoder_init(&encoder, hello, sizeof(hello));
    
    /* 消息头 */
    UA_Encoder_writeUInt32(&encoder, 0x48454C47);  // "HEL"
    UA_Encoder_writeUInt32(&encoder, 28);          // 消息大小
    UA_Encoder_writeUInt32(&encoder, 0);           // 分块类型
    
    /* 消息体 */
    UA_Encoder_writeUInt32(&encoder, 0);           // 协议版本
    UA_Encoder_writeUInt32(&encoder, 65536);       // 接收缓冲区大小
    UA_Encoder_writeUInt32(&encoder, 65536);       // 发送缓冲区大小
    UA_Encoder_writeUInt32(&encoder, 65536);       // 最大消息大小
    UA_Encoder_writeUInt32(&encoder, 0);           // 最大分块数
    
    send(client->socket, hello, sizeof(hello), 0);
    
    /* 接收Acknowledge消息 */
    UA_Byte ack[28];
    ssize_t bytes = recv(client->socket, ack, sizeof(ack), 0);
    if(bytes != 28) {
        close(client->socket);
        return UA_STATUSCODE_BADCOMMUNICATIONERROR;
    }
    
    printf("Connected to %s:%d\n", hostname, port);
    return UA_STATUSCODE_GOOD;
}

/* 读取节点值 */
UA_StatusCode UA_Client_read(UA_Client *client, const UA_NodeId *nodeId, UA_Variant *value) {
    if(!client || client->socket < 0) {
        return UA_STATUSCODE_BADNOTCONNECTED;
    }
    
    /* 准备读取请求 */
    UA_Byte request[256];
    UA_Encoder encoder;
    UA_Encoder_init(&encoder, request, sizeof(request));
    
    /* 消息头 */
    UA_Encoder_writeUInt32(&encoder, 0x4D534747);  // "MSG"
    UA_Encoder_writeUInt32(&encoder, 0);           // 临时大小
    UA_Encoder_writeUInt32(&encoder, 0);           // 分块类型
    
    /* 请求体 */
    UA_Encoder_writeUInt32(&encoder, 0x4D534752);  // "R" - ReadRequest
    UA_Encoder_writeUInt32(&encoder, client->requestId++);
    UA_Encoder_writeUInt32(&encoder, 0);           // TimestampsToReturn
    
    /* 要读取的节点 */
    UA_Encoder_writeUInt32(&encoder, 1);           // NodesToRead数量
    UA_Encoder_writeNodeId(&encoder, nodeId);
    
    /* 更新消息大小 */
    UA_UInt32 messageSize = encoder.position;
    memcpy(&request[4], &messageSize, 4);
    
    /* 发送请求 */
    send(client->socket, request, messageSize, 0);
    
    /* 接收响应 */
    UA_Byte response[1024];
    ssize_t bytes = recv(client->socket, response, sizeof(response), 0);
    if(bytes <= 0) {
        return UA_STATUSCODE_BADCOMMUNICATIONERROR;
    }
    
    /* 解码响应 */
    UA_Decoder decoder;
    UA_Decoder_init(&decoder, response, bytes);
    
    /* 跳过消息头 */
    decoder.position += 12;  // 消息头大小
    
    /* 读取响应类型和RequestId */
    UA_UInt32 responseType = UA_Decoder_readUInt32(&decoder);
    UA_UInt32 requestId = UA_Decoder_readUInt32(&decoder);
    
    if(responseType != 0x4D534752) {  // 不是ReadResponse
        return UA_STATUSCODE_BADUNEXPECTEDERROR;
    }
    
    /* 读取结果数量 */
    UA_UInt32 resultsCount = UA_Decoder_readUInt32(&decoder);
    if(resultsCount != 1) {
        return UA_STATUSCODE_BADUNEXPECTEDERROR;
    }
    
    /* 读取状态码 */
    UA_StatusCode status = UA_Decoder_readUInt32(&decoder);
    if(status != UA_STATUSCODE_GOOD) {
        return status;
    }
    
    /* 读取数据值 */
    return UA_Decoder_readVariant(&decoder, value);
}

/* 写入节点值 */
UA_StatusCode UA_Client_write(UA_Client *client, const UA_NodeId *nodeId, const UA_Variant *value) {
    if(!client || client->socket < 0) {
        return UA_STATUSCODE_BADNOTCONNECTED;
    }
    
    /* 准备写入请求 */
    UA_Byte request[512];
    UA_Encoder encoder;
    UA_Encoder_init(&encoder, request, sizeof(request));
    
    /* 消息头 */
    UA_Encoder_writeUInt32(&encoder, 0x4D534747);  // "MSG"
    UA_Encoder_writeUInt32(&encoder, 0);           // 临时大小
    UA_Encoder_writeUInt32(&encoder, 0);           // 分块类型
    
    /* 请求体 */
    UA_Encoder_writeUInt32(&encoder, 0x4D534757);  // "W" - WriteRequest
    UA_Encoder_writeUInt32(&encoder, client->requestId++);
    
    /* 要写入的节点 */
    UA_Encoder_writeUInt32(&encoder, 1);           // NodesToWrite数量
    
    /* 写入节点ID */
    UA_Encoder_writeNodeId(&encoder, nodeId);
    
    /* 写入值 */
    UA_Encoder_writeUInt32(&encoder, 13);          // AttributeId: Value
    UA_Encoder_writeVariant(&encoder, value);
    
    /* 更新消息大小 */
    UA_UInt32 messageSize = encoder.position;
    memcpy(&request[4], &messageSize, 4);
    
    /* 发送请求 */
    send(client->socket, request, messageSize, 0);
    
    /* 接收响应 */
    UA_Byte response[256];
    ssize_t bytes = recv(client->socket, response, sizeof(response), 0);
    if(bytes <= 0) {
        return UA_STATUSCODE_BADCOMMUNICATIONERROR;
    }
    
    /* 解码响应 */
    UA_Decoder decoder;
    UA_Decoder_init(&decoder, response, bytes);
    
    /* 跳过消息头 */
    decoder.position += 12;
    
    /* 读取响应类型和RequestId */
    UA_UInt32 responseType = UA_Decoder_readUInt32(&decoder);
    UA_UInt32 requestId = UA_Decoder_readUInt32(&decoder);
    
    if(responseType != 0x4D534757) {  // 不是WriteResponse
        return UA_STATUSCODE_BADUNEXPECTEDERROR;
    }
    
    /* 读取结果数量 */
    UA_UInt32 resultsCount = UA_Decoder_readUInt32(&decoder);
    if(resultsCount != 1) {
        return UA_STATUSCODE_BADUNEXPECTEDERROR;
    }
    
    /* 返回状态码 */
    return UA_Decoder_readUInt32(&decoder);
}

四、使用示例

4.1 服务器示例

c 复制代码
/**
 * @file server_example.c
 * @brief OPC UA 服务器示例
 */

#include "opcua_server.h"
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

UA_Server *g_server = NULL;

/* 信号处理 */
void signal_handler(int sig) {
    printf("Shutting down server...\n");
    if(g_server) {
        UA_Server_stop(g_server);
        UA_Server_delete(g_server);
    }
    exit(0);
}

/* 读取回调 */
void on_read_callback(UA_NodeId nodeId, UA_Variant *value) {
    static int counter = 0;
    
    if(nodeId.identifierType == UA_NODEIDTYPE_NUMERIC && 
       nodeId.identifier.numeric == 1001) {
        value->type = UA_VARIANT_DATA_INT32;
        value->data.int32 = ++counter;
    }
}

/* 写入回调 */
void on_write_callback(UA_NodeId nodeId, UA_Variant *value) {
    printf("Node 0x%X written with value: ", nodeId.identifier.numeric);
    
    switch(value->type) {
        case UA_VARIANT_DATA_INT32:
            printf("%d\n", value->data.int32);
            break;
        case UA_VARIANT_DATA_DOUBLE:
            printf("%f\n", value->data.doubleVal);
            break;
        case UA_VARIANT_DATA_STRING:
            printf("%s\n", value->data.string);
            break;
        default:
            printf("Unknown type\n");
            break;
    }
}

int main(int argc, char *argv[]) {
    signal(SIGINT, signal_handler);
    signal(SIGTERM, signal_handler);
    
    /* 服务器配置 */
    UA_ServerConfig config = {
        .port = 4840,
        .maxConnections = 10,
        .maxWorkerThreads = 4,
        .enableSecurity = false,
        .applicationUri = "urn:localhost:MyOPCUAServer",
        .productUri = "urn:mycompany:MyProduct",
        .applicationName = "My OPC UA Server"
    };
    
    /* 创建服务器 */
    g_server = UA_Server_new(&config);
    if(!g_server) {
        fprintf(stderr, "Failed to create server\n");
        return 1;
    }
    
    /* 设置回调 */
    g_server->callbacks.onRead = on_read_callback;
    g_server->callbacks.onWrite = on_write_callback;
    
    /* 添加示例节点 */
    UA_NodeId parentNodeId = UA_NODEID_NUMERIC(0, UA_NS0ID_OBJECTSFOLDER);
    UA_NodeId referenceTypeId = UA_NODEID_NUMERIC(0, UA_NS0ID_HASCOMPONENT);
    UA_QualifiedName browseName = {"Counter", 1};
    
    UA_VariableAttributes attr = {0};
    attr.displayName = "Counter";
    attr.description = "A simple counter variable";
    attr.accessLevel = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE;
    attr.userAccessLevel = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE;
    attr.minimumSamplingInterval = 1000.0;
    attr.historizing = false;
    
    UA_Variant_init(&attr.value);
    attr.value.type = UA_VARIANT_DATA_INT32;
    attr.value.data.int32 = 0;
    
    UA_NodeId variableNodeId;
    UA_StatusCode status = UA_Server_addVariableNode(
        g_server,
        &parentNodeId,
        &referenceTypeId,
        &browseName,
        UA_NODEID_NUMERIC(0, UA_NS0ID_BASEDATAVARIABLETYPE),
        &attr,
        NULL,
        &variableNodeId
    );
    
    if(status != UA_STATUSCODE_GOOD) {
        fprintf(stderr, "Failed to add variable node: 0x%08X\n", status);
        UA_Server_delete(g_server);
        return 1;
    }
    
    printf("Added variable node: ns=%d, id=%d\n", 
           variableNodeId.namespaceIndex, variableNodeId.identifier.numeric);
    
    /* 运行服务器 */
    printf("Server is running on opc.tcp://localhost:4840\n");
    printf("Press Ctrl+C to exit\n");
    
    status = UA_Server_run(g_server);
    if(status != UA_STATUSCODE_GOOD) {
        fprintf(stderr, "Server error: 0x%08X\n", status);
    }
    
    UA_Server_delete(g_server);
    return 0;
}

4.2 客户端示例

c 复制代码
/**
 * @file client_example.c
 * @brief OPC UA 客户端示例
 */

#include "opcua_client.h"
#include <stdio.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    const char *endpointUrl = "opc.tcp://localhost:4840";
    
    if(argc > 1) {
        endpointUrl = argv[1];
    }
    
    /* 创建客户端 */
    UA_Client *client = UA_Client_new();
    if(!client) {
        fprintf(stderr, "Failed to create client\n");
        return 1;
    }
    
    /* 连接服务器 */
    UA_StatusCode status = UA_Client_connect(client, endpointUrl);
    if(status != UA_STATUSCODE_GOOD) {
        fprintf(stderr, "Failed to connect: 0x%08X\n", status);
        UA_Client_delete(client);
        return 1;
    }
    
    printf("Connected to %s\n", endpointUrl);
    
    /* 读取节点值 */
    UA_NodeId nodeId = UA_NODEID_NUMERIC(1, 1001);  // 假设的节点ID
    
    for(int i = 0; i < 10; i++) {
        UA_Variant value;
        
        status = UA_Client_read(client, &nodeId, &value);
        if(status == UA_STATUSCODE_GOOD) {
            if(value.type == UA_VARIANT_DATA_INT32) {
                printf("Read value: %d\n", value.data.int32);
            } else {
                printf("Unexpected data type: %d\n", value.type);
            }
        } else {
            printf("Read failed: 0x%08X\n", status);
        }
        
        sleep(1);
    }
    
    /* 写入节点值 */
    UA_Variant writeValue;
    writeValue.type = UA_VARIANT_DATA_INT32;
    writeValue.data.int32 = 42;
    
    status = UA_Client_write(client, &nodeId, &writeValue);
    if(status == UA_STATUSCODE_GOOD) {
        printf("Write successful\n");
    } else {
        printf("Write failed: 0x%08X\n", status);
    }
    
    /* 断开连接 */
    UA_Client_disconnect(client);
    UA_Client_delete(client);
    
    return 0;
}

五、编译与构建

5.1 CMakeLists.txt

cmake 复制代码
cmake_minimum_required(VERSION 3.10)
project(opcua_stack C)

set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)

# 包含目录
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include)

# 源文件
set(OPCUA_SOURCES
    src/opcua_types.c
    src/opcua_binary.c
    src/opcua_server.c
    src/opcua_client.c
    src/opcua_security.c
    src/opcua_subscription.c
)

# 服务器可执行文件
add_executable(opcua_server
    examples/server_example.c
    ${OPCUA_SOURCES}
)

# 客户端可执行文件
add_executable(opcua_client
    examples/client_example.c
    ${OPCUA_SOURCES}
)

# 库文件
add_library(opcua STATIC ${OPCUA_SOURCES})

# 链接库
target_link_libraries(opcua_server opcua pthread)
target_link_libraries(opcua_client opcua pthread)

5.2 构建脚本

bash 复制代码
#!/bin/bash
# build.sh

# 创建构建目录
mkdir -p build
cd build

# 使用CMake生成Makefile
cmake -DCMAKE_BUILD_TYPE=Release ..

# 编译
make -j4

echo "构建完成!"
echo "可执行文件在 build/ 目录中"

参考代码 OPC UA 协议栈C语言实现 www.youwenfan.com/contentcsv/70675.html

六、高级特性实现

6.1 安全与加密

c 复制代码
/**
 * @file opcua_security.c
 * @brief OPC UA 安全模块
 */

#include "opcua_security.h"
#include <openssl/rsa.h>
#include <openssl/pem.h>
#include <openssl/err.h>

/* 安全策略 */
typedef enum {
    UA_SECURITYPOLICY_NONE = 0,
    UA_SECURITYPOLICY_BASIC128RSA15,
    UA_SECURITYPOLICY_BASIC256,
    UA_SECURITYPOLICY_BASIC256SHA256
} UA_SecurityPolicy;

/* 安全令牌 */
typedef struct {
    UA_UInt32 tokenId;
    UA_DateTime createdAt;
    UA_DateTime revisedLifetime;
    UA_ByteString serverNonce;
} UA_ChannelSecurityToken;

/* 消息安全 */
typedef struct {
    UA_SecurityPolicy policy;
    RSA *localPrivateKey;
    RSA *remotePublicKey;
    UA_ByteString localNonce;
    UA_ByteString remoteNonce;
} UA_SecurityContext;

/* 初始化安全上下文 */
UA_StatusCode UA_SecurityContext_init(UA_SecurityContext *ctx, 
                                     UA_SecurityPolicy policy) {
    ctx->policy = policy;
    ctx->localPrivateKey = NULL;
    ctx->remotePublicKey = NULL;
    
    /* 生成本地Nonce */
    UA_ByteString_allocBuffer(&ctx->localNonce, 32);
    RAND_bytes(ctx->localNonce.data, ctx->localNonce.length);
    
    return UA_STATUSCODE_GOOD;
}

/* 消息签名 */
UA_StatusCode UA_SecurityContext_sign(UA_SecurityContext *ctx,
                                     const UA_ByteString *message,
                                     UA_ByteString *signature) {
    if(ctx->policy == UA_SECURITYPOLICY_NONE || !ctx->localPrivateKey) {
        return UA_STATUSCODE_BADSECURITYCHECKSFAILED;
    }
    
    unsigned int siglen = RSA_size(ctx->localPrivateKey);
    UA_ByteString_allocBuffer(signature, siglen);
    
    unsigned char hash[SHA256_DIGEST_LENGTH];
    SHA256(message->data, message->length, hash);
    
    int result = RSA_sign(NID_sha256, hash, SHA256_DIGEST_LENGTH,
                         signature->data, &siglen, ctx->localPrivateKey);
    
    if(result != 1) {
        UA_ByteString_clear(signature);
        return UA_STATUSCODE_BADSECURITYCHECKSFAILED;
    }
    
    signature->length = siglen;
    return UA_STATUSCODE_GOOD;
}

/* 消息验证 */
UA_StatusCode UA_SecurityContext_verify(UA_SecurityContext *ctx,
                                       const UA_ByteString *message,
                                       const UA_ByteString *signature) {
    if(ctx->policy == UA_SECURITYPOLICY_NONE || !ctx->remotePublicKey) {
        return UA_STATUSCODE_BADSECURITYCHECKSFAILED;
    }
    
    unsigned char hash[SHA256_DIGEST_LENGTH];
    SHA256(message->data, message->length, hash);
    
    int result = RSA_verify(NID_sha256, hash, SHA256_DIGEST_LENGTH,
                           signature->data, signature->length,
                           ctx->remotePublicKey);
    
    return (result == 1) ? UA_STATUSCODE_GOOD : UA_STATUSCODE_BADSECURITYCHECKSFAILED;
}

七、测试与验证

7.1 单元测试

c 复制代码
/**
 * @file test_opcua.c
 * @brief OPC UA 单元测试
 */

#include "opcua_types.h"
#include "opcua_binary.h"
#include <assert.h>
#include <string.h>

void test_binary_encoding(void) {
    printf("Testing binary encoding...\n");
    
    UA_Byte buffer[256];
    UA_Encoder enc;
    UA_Decoder dec;
    
    /* 测试基本类型 */
    UA_Encoder_init(&enc, buffer, sizeof(buffer));
    UA_Encoder_writeUInt32(&enc, 0x12345678);
    UA_Encoder_writeInt32(&enc, -123456);
    
    UA_Decoder_init(&dec, buffer, enc.position);
    assert(UA_Decoder_readUInt32(&dec) == 0x12345678);
    assert(UA_Decoder_readInt32(&dec) == -123456);
    
    /* 测试字符串 */
    UA_String str = {5, (UA_UInt16*)"Hello"};
    UA_Encoder_init(&enc, buffer, sizeof(buffer));
    UA_Encoder_writeString(&enc, &str);
    
    UA_Decoder_init(&dec, buffer, enc.position);
    UA_String decodedStr;
    UA_Decoder_readString(&dec, &decodedStr);
    
    assert(decodedStr.length == 5);
    assert(memcmp(decodedStr.data, "Hello", 5) == 0);
    
    printf("All tests passed!\n");
}

void test_node_id(void) {
    printf("Testing NodeId encoding...\n");
    
    UA_NodeId node1 = UA_NODEID_NUMERIC(1, 1234);
    UA_NodeId node2 = UA_NODEID_STRING(2, "MyVariable");
    
    UA_Byte buffer[256];
    UA_Encoder enc;
    UA_Decoder dec;
    
    /* 编码和解码数字节点ID */
    UA_Encoder_init(&enc, buffer, sizeof(buffer));
    UA_Encoder_writeNodeId(&enc, &node1);
    
    UA_Decoder_init(&dec, buffer, enc.position);
    UA_NodeId decodedNode1;
    UA_Decoder_readNodeId(&dec, &decodedNode1);
    
    assert(decodedNode1.identifierType == UA_NODEIDTYPE_NUMERIC);
    assert(decodedNode1.namespaceIndex == 1);
    assert(decodedNode1.identifier.numeric == 1234);
    
    printf("NodeId tests passed!\n");
}

int main(void) {
    test_binary_encoding();
    test_node_id();
    return 0;
}

八、使用开源项目的最佳实践

8.1 使用 open62541

c 复制代码
/* 使用 open62541 的简单示例 */
#include <open62541/server.h>
#include <open62541/server_config_default.h>
#include <signal.h>
#include <stdlib.h>

UA_Boolean running = true;

static void stopHandler(int sig) {
    running = false;
}

int main(void) {
    signal(SIGINT, stopHandler);
    signal(SIGTERM, stopHandler);
    
    /* 创建服务器 */
    UA_Server *server = UA_Server_new();
    UA_ServerConfig_setDefault(UA_Server_getConfig(server));
    
    /* 添加变量节点 */
    UA_VariableAttributes attr = UA_VariableAttributes_default;
    UA_Int32 myInteger = 42;
    UA_Variant_setScalar(&attr.value, &myInteger, &UA_TYPES[UA_TYPES_INT32]);
    attr.description = UA_LOCALIZEDTEXT("en-US", "A sample variable");
    attr.displayName = UA_LOCALIZEDTEXT("en-US", "SampleVariable");
    attr.accessLevel = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE;
    
    UA_NodeId myIntegerNodeId = UA_NODEID_STRING(1, "sample-integer");
    UA_QualifiedName myIntegerName = UA_QUALIFIEDNAME(1, "Sample Variable");
    UA_NodeId parentNodeId = UA_NODEID_NUMERIC(0, UA_NS0ID_OBJECTSFOLDER);
    UA_NodeId parentReferenceNodeId = UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES);
    UA_NodeId variableType = UA_NODEID_NUMERIC(0, UA_NS0ID_BASEDATAVARIABLETYPE);
    
    UA_Server_addVariableNode(server, myIntegerNodeId,
                             parentNodeId, parentReferenceNodeId,
                             myIntegerName, variableType,
                             attr, NULL, NULL);
    
    /* 运行服务器 */
    UA_StatusCode retval = UA_Server_run(server, &running);
    
    UA_Server_delete(server);
    return retval == UA_STATUSCODE_GOOD ? EXIT_SUCCESS : EXIT_FAILURE;
}

九、总结

9.1 实现要点

  1. 分层架构:传输层、编码层、服务层
  2. 内存管理:合理的内存分配和释放策略
  3. 线程安全:多线程环境下的并发控制
  4. 错误处理:完善的错误码和异常处理
  5. 扩展性:插件化架构支持功能扩展

9.2 生产建议

  1. 使用开源实现:推荐 open62541 作为基础
  2. 安全第一:实现完整的安全机制
  3. 性能优化:考虑内存池、连接池等技术
  4. 测试覆盖:全面的单元测试和集成测试
  5. 标准兼容:通过 OPC UA 兼容性测试
相关推荐
song5012 小时前
Ascend C 算子开发:从入门到上手
c语言·开发语言·图像处理·人工智能·分布式·flutter·交互
小a杰.2 小时前
Ascend C编程语言进阶:高性能算子开发技巧
android·c语言·开发语言
全糖可乐气泡水2 小时前
Codex适配国产信创环境安装部署与技术适配全解析
开发语言·git·python·算法·百度
雨落在了我的手上2 小时前
初始java(十):类和对象(⼆)
java·开发语言
LeocenaY2 小时前
搜集的一些测开面试题
开发语言·python
threelab3 小时前
Three.js 加载 3D Tiles 瓦片数据 | 三维可视化 / AI 提示词
开发语言·前端·javascript·人工智能·3d·着色器
_洋4 小时前
Three.js加载 .obj文件 和 .gltf文件
开发语言·javascript·ecmascript
wjs20244 小时前
Font Awesome 性别图标
开发语言
SmartBrain4 小时前
AI全栈开发(SDD):慢病管理系统工程级设计
java·大数据·开发语言·人工智能·架构·aigc