简介:面向工业自动化开发者的C#读取WinCC归档数据库示例工程,聚焦S7-300过程归档与历史数据查询场景。压缩包内含完整Visual Studio项目,共39个文件:10个C#源文件、3个可直接运行的exe、2个dll动态库,另有配置、资源、解决方案与少量缓存文件,整体仅77KB,便于快速参考。已有386人学习下载。代码演示了通过System.Data.SqlClient建立WinCC归档库连接、编写SQL检索时间区间数据、封装数据访问对象,并涉及异步编程、异常处理与权限安全等关键点;清晰的项目结构包含Form1窗体、Program入口与App.config配置,适合需要从PLC或WinCC读取归档数据的初学者与集成工程师。通过阅读源程序,可理解WinCC历史库表结构与C#调用方式,掌握数据库连接串配置与查询封装技巧,并复用其核心类缩短自研读取模块开发周期。
1. 先搞明白“C#读取WINCC归档数据库”到底是什么需求
读归档之前,先记住一个关键事实:WINCC 的历史数据并不躺在某个私有格式的文件里,而是落在它自己内置的 SQL Server 实例中。也就是说,用 C# 的 SqlClient 连上那个实例,像查普通业务库一样做 SELECT,就能把一年甚至几年的归档值捞出来。像“C#读取WINCC归档数据库源程序.rar”这类交付包,打包的通常就是一套连接、探查、查询、类型转换和导出逻辑,目标很直接:让 C# 上位机能拿到历史数据,做报表、画曲线、转储给上层系统。适合正在做 C# 上位机、要接西门子 WINCC 历史数据的工程师,也适合刚入门 C# 但被现场数据需求砸到的新手。
2. 连接串与归档表结构:先确认实例名、库名和列名
2.1 归档库的本质:一个被 WINCC 托管起来的 SQL Server
WINCC 从 6.0 开始不再用私有文件保存历史归档,而是统一写入内置的 SQL Server 实例。这个实例的默认名字一般是“机器名\WINCC”,归档库名通常带工程名前缀。很多现场工程师第一次拿到这类源程序时,最大的心理障碍就在“WINCC”这个名字上,以为一定要装西门子专有的数据访问组件。实际上 C# 侧要做的只是把 SqlClient 指向那个实例,然后执行 SELECT。
常用两种连接串写法:
// 方式一:SqlClient,跨 .NET Framework / .NET 6+ 均可用 string connStr = @"Data Source=.\WINCC;Initial Catalog=CC_Demo_Archive;User ID=sa;Password=123456;Connect Timeout=30;"; // 方式二:OLEDB,老系统兼容性考虑 string connStr = @"Provider=SQLOLEDB;Data Source=.\WINCC;Initial Catalog=CC_Demo_Archive;User ID=sa;Password=123456;";参数说明:
Data Source=.\WINCC:.\代表本机,远程连接时改成服务器名\WINCC。User ID=sa;Password=...:要求 SQL Server 启用混合验证。如果现场配置的是 Windows 验证,直接去掉用户密码,改用Integrated Security=True。Connect Timeout=30:归档实例首次冷启动时要加载大量库页缓存,默认 15 秒经常不够,我习惯给 30 秒以上。
很多现场工程师(包括我自己早期)都卡在Initial Catalog上:拿着示例库名去套现场,结果库里根本没有这个名字。归档库的真实库名在项目部署时决定,后面可能还跟着日期后缀。所以拿到任何源程序,第一件事不是改密码,是先在实例上把数据库列表捞出来:
SELECT name FROM sys.databases WHERE name LIKE 'CC\_%' ESCAPE '\' ORDER BY name;这段 SQL 的逻辑:LIKE 'CC\_%'匹配以CC_开头的库名;ESCAPE '\'把下划线从通配符转义成普通字符,否则_会匹配任意一个字符,把不想看的库也带上。列出的结果里挑出当前工程的归档库,替换到连接串里再继续。
提示:如果这里就报“找不到服务器实例”,先确认两件事:SQL Server(WINCC)服务是否在运行;远程连接时是否启用了 TCP/IP。很多连不上不是代码问题,而是服务或协议没起来。
2.2 用 SQL 探查表结构:别让列名真面目坑了你
如果你拿到的是一份写好的源程序,最想删掉的可能就是我接下来这段话:先别急着找“读数据的那个方法”,花十分钟把归档库的表结构探出来。WINCC 归档库在不同版本、不同项目配置下,时间轴表、值表、变量定义表的名字经常变。写死的表名和列名,换个现场就翻车。
SELECT t.name AS TableName, c.name AS ColumnName, ty.name AS DataType FROM sys.tables t INNER JOIN sys.columns c ON t.object_id = c.object_id INNER JOIN sys.types ty ON c.user_type_id = ty.user_type_id WHERE t.name LIKE '%ARCH%' OR t.name LIKE '%TIMEBAR%' OR t.name LIKE '%ALG%' OR t.name LIKE '%MSG%' ORDER BY t.name, c.column_id;这段 SQL 用sys.tables、sys.columns、sys.types三张系统视图拼出“表—列—数据类型”的宽表,WHERE 筛掉和归档无关的对象。拿到结果后重点看三件事:
- 时间轴表叫什么,时间列是不是
datetime。 - 值表叫什么,值列是
float、real还是被包成varbinary。 - 变量定义表和值表之间用哪个 ID 关联。
探完表结构,我一般会把结果整理成下面这样一张表放在工程注释里,方便接手的人快速对照:
| 作用 | 常见表名 | 常见列 |
|---|---|---|
| 时间轴 | TIMEBAR | OPTTIME, TIMEBARID |
| 归档值 | ARCHIVE | VALUE, VALUEID, TIMEBARID |
| 变量定义 | PROCESSPARAMS | VALUEID, NAME |
注意:以上表名和列名是我在项目里常见的样式,不同 WINCC 版本存在差异。现场以刚才的探查结果为准,不要拿着这张表硬套。
2.3 三条读取路线选型:SqlClient、OLEDB 还是 OPC UA
“怎么读归档”这个问题的答案不是唯一的。按现场约束不同,我通常会从三条路线里面选:
- SqlClient:直接连 WINCC 内嵌 SQL Server,权限够、表结构清楚时最理想。
- OLEDB:老项目、老驱动环境偶尔会遇到,连接串带一个 Provider 前缀,但要格外注意驱动位数。
- OPC UA:WINCC 7.x 以后支持配置 OPC UA Server,C# 走 UA 客户端读历史数据,不用面对表结构,但要在 WINCC 侧做证书和用户配置。
选型建议用一张表说清:
| 路线 | 连接要点 | 权限要求 | 适用场景 |
|---|---|---|---|
| SqlClient | Data Source=.\WINCC | SQL 账号或 Windows 账号 | 报表、批量导出、离线分析 |
| OLEDB | Provider=SQLOLEDB;Data Source=... | 同上 | 老驱动、老系统兼容 |
| OPC UA | opc.tcp://IP:端口 | UA 用户、证书配置 | 在线+历史混合读取、不想碰数据库 |
很多 C# 上位机框架里同时封装两条通道:在线值走 OPC(比如 C# 连接西门子 OPC 服务器拿实时数据),历史归档走 SqlClient 直接读数据库。如果你只需要历史报表,SqlClient 足够,没必要为了读归档强行上 OPC UA;如果既读在线又要历史,OPC UA 的配置成本就值得付了。
3. 用 C# 把归档历史数据读出来:三段能直接改的代码
3.1 冒烟测试:连库、读最近记录、打印列名
拿到归档需求,我不会一上来就写完整查询,而是先跑一个最小的冒烟测试:连接库、读最近 100 条、打印出来。代码量控制在 30 行以内,跑通了再扩展。
using System; using System.Data.SqlClient; class ArchiveSmokeTest { static void Main() { string connStr = @"Data Source=.\WINCC;Initial Catalog=CC_Demo_Archive;User ID=sa;Password=123456;Connect Timeout=30;"; string sql = @" SELECT TOP 100 t.OPTTIME, a.VALUE FROM TIMEBAR t INNER JOIN ARCHIVE a ON t.TIMEBARID = a.TIMEBARID ORDER BY t.OPTTIME DESC;"; using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); using (SqlCommand cmd = new SqlCommand(sql, conn)) using (SqlDataReader reader = cmd.ExecuteReader()) { while (reader.Read()) { DateTime time = reader.GetDateTime(0); double value = Convert.ToDouble(reader["VALUE"]); Console.WriteLine($"{time:yyyy-MM-dd HH:mm:ss} {value}"); } } } } }逻辑说明:using块保证连接随作用域释放;TOP 100限流,避免把大表全量拖回;Convert.ToDouble(reader["VALUE"])是对值类型的防御写法。参数上,Connect Timeout=30应对冷缓存,.\\WINCC是本地实例。如果这里就抛登录失败或库名不对,先回第 2 章把连接串和表结构确认一遍,不要继续往下调。
这一段跑通后,可以顺手用reader.GetSchemaTable()把列打出来,跟 2.2 节探查结果对一遍。很多时候你以为的列名和实际库里的列名就差一两个字母,静默出错比报错更难受。
3.2 按时间窗过滤:参数化查询与分段策略
正式做报表时,查询条件基本都是“给我某段时间的数据”。这里最不推荐的做法是把时间字符串直接拼进 SQL,比如WHERE OPTTIME > '2024-01-01 00:00:00',字符串格式稍有偏差就查不到或者误报。推荐参数化查询:
string sql = @" SELECT t.OPTTIME, a.VALUE, a.VALUESTATE FROM TIMEBAR t INNER JOIN ARCHIVE a ON t.TIMEBARID = a.TIMEBARID WHERE t.OPTTIME >= @start AND t.OPTTIME < @end ORDER BY t.OPTTIME ASC;"; using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.Add("@start", SqlDbType.DateTime).Value = startTime; cmd.Parameters.Add("@end", SqlDbType.DateTime).Value = endTime; using (SqlDataReader reader = cmd.ExecuteReader()) { while (reader.Read()) { // 逐行处理,不要在这里攒字符串 } } }代码里用>= @start AND < @end开区间,而不是 BETWEEN,原因很简单:BETWEEN 是闭区间,秒级数据在边界处会重复;相邻分段用开区间写法能划清边界,避免同一条记录被读两次。参数显式指定SqlDbType.DateTime,比AddWithValue更利于 SQL Server 的索引选择,大数据量下的差异非常明显。
实际项目里查一年数据时,我会按天循环:
DateTime cursor = startTime; while (cursor < endTime) { DateTime next = cursor.AddDays(1); if (next > endTime) next = endTime; ReadArchiveRange(cursor, next); // 每次只查一天 cursor = next; }分段的意义在于:归档表数据量随时可能上亿,一次性查一年会把 SQL Server 和 C# 两侧内存同时打爆。单段 10 万行以内是经验值,具体要看归档变量的数量和采集频率。分段之后每一段独立查询、独立处理,即使中途断了,也只要从断点继续,不用从头再来。
3.3 值类型转换与 CSV 导出:告别一读就崩
另一个高频坑是:归档值列未必是 double。不同版本的 WINCC 可能把值存成float、real、decimal甚至byte[],直接reader.GetDouble(列名)在某些列类型下会抛 InvalidCastException。我通常会写一个统一转换函数:
private static double ToDouble(object value) { switch (value) { case float f: return f; case double d: return d; case int i: return i; case decimal m: return (double)m; case byte[] bytes when bytes.Length >= 4: return BitConverter.ToDouble(bytes, 0); default: throw new InvalidCastException($"不支持的归档值类型: {value?.GetType().Name}"); } }case byte[] bytes when bytes.Length >= 4是 C# 的模式匹配写法,专门处理值列被存成二进制的情况。优先按 double 解包,解不了再走异常分支,至少能给出明确提示,而不是模棱两可的类型转换报错。
导出 CSV 时也要注意编码和写入方式:
using (StreamWriter sw = new StreamWriter(@"D:\archive_export.csv", false, Encoding.UTF8)) { sw.WriteLine("Time,Value,State"); foreach (DataRow row in dt.Rows) { sw.WriteLine($"{row["Time"]},{row["Value"]},{row["State"]}"); } }new StreamWriter第二个参数false表示覆盖写,Encoding.UTF8显式指定,避免中文系统下默认编码导致 Excel 打开乱码。逐行写而不是拼一个大字符串再File.WriteAllText,是为了控制内存峰值。如果导出中途失败,还能从已写入的行数判断进度,不至于全丢。
4. 读取 WINCC 归档库的避坑清单:最容易翻车的 5 个位置
4.1 登录失败:实例名、库名和账号三位要对齐
现象:SqlConnection.Open()抛Login failed for user 'sa',或者Cannot open database "CC_xxx" requested by the login。
原因:现场 WINCC 内置 SQL 实例的 sa 密码和你代码里写的不一致;或者Initial Catalog根本不存在。这类问题看着像权限问题,其实经常是库名没对齐。
解决:先用 SQL 管理工具以 Windows 身份登录.\WINCC实例,列出所有数据库:
USE [master]; GO SELECT name FROM sys.databases; GO然后用一条测试连接确认账号可用。如果 sa 密码实在不知道,优先考虑改成Integrated Security=True,因为 WINCC 服务一般用本地 Windows 账号跑,这个账号大概率能访问归档库。不到万不得已别去重置 sa 密码,否则 WINCC 运行时自己反而可能连不上归档库,影响面更大。
4.2 平台位数不匹配:OLEDB 驱动加载崩溃
现象:程序一运行到new OleDbConnection()就抛“试图加载格式不正确的程序”,或者报缺少 Provider,而同一台机器上用管理工具却连得挺好。
原因:老 OLEDB 驱动是 32 位的,C# 进程在 x64 模式下加载失败。最常见就是 Visual Studio 默认的 Any CPU 在 64 位系统上跑成 64 位进程,老驱动根本加载不进去。
解决:能换 SqlClient 就换 SqlClient,这是最省事的方案。确需保留 OLEDB 时,把工程属性里的“首选 32 位”按驱动实际位数设成一致,发布机器也要装对应位数驱动。检查方法很简单:用Environment.Is64BitProcess打印一下当前进程位数,再对照驱动版本,基本一眼能定位。
4.3 查询超时与内存暴涨:一次把全库拉满了
现象:查询跑几十秒后抛 CommandTimeout,或者 DataTable 占用上 GB 内存,程序直接卡死。
原因:没有加时间过滤,或者过滤条件没有落到索引上;归档表是高频写入表,几年数据全量加载,SQL Server 生成结果集和 C# 装载结果集两边同时吃紧。
解决:先TOP N冒烟,再按时间分段;每段处理完释放 DataTable。如果查询仍然慢,先看执行计划,重点确认时间列的过滤有没有走索引;有的归档表时间列确实没索引,那就要和现场确认能否在停机窗口补一个,归档库是 WINCC 在维护,加索引前一定要评估。
4.4 时间偏差 8 小时:UTC 和本地时间混着算
现象:读出来的OPTTIME和 WINCC 趋势画面对不上,普遍差 8 小时或一个固定偏移。
原因:归档库存的时间,不同项目可能存本地时间也可能存 UTC,WinCC 在画面层又做了时区转换;C# 默认把读到的DateTime当 Unspecified,ToString()时又按本机时区格式化,一来一回就偏了。
解决:先做一次对照实验:从库里读一条已知时间的数据,和 WINCC 原始趋势画面对比,确认库内到底是哪种语义,再统一处理:
DateTime raw = reader.GetDateTime(0); DateTime displayTime = DateTime.SpecifyKind(raw, DateTimeKind.Unspecified).ToLocalTime();DateTime.SpecifyKind只是设置 Kind 标记,不改变数值;ToLocalTime()才做真正转换。如果库内已经是本地时间,就不要调 ToLocalTime 了。团队里最好固化这一段逻辑,别在导出层临时补小时数,那样最容易改出隐藏 bug。
4.5 运行中的 WINCC 被查询拖住:归档写入受影响
现象:一边用 C# 读历史数据,一边 WINCC 画面上的趋势出现空洞,或者归档写入明显变慢。
原因:查询长时间占用资源,或者锁竞争影响到了 WINCC 的归档写入进程。归档写入是 WINCC 的核心功能,读写冲突处理不好就会丢数据。
解决:查询都加只读隔离级别,避免长时间持有不必要的锁:
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;读库尽量挑停机或低峰时段。如果现场必须实时读,就在应用层加并发开关,控制同时运行的查询数量和单次查询时长,避免多个大查询同时打在归档实例上。
5. 把读取做成断点续读:增量拉取与正确性自查
前面的代码能跑通全量,接下来就要考虑增量问题:如果程序每小时拉一次归档,总不能每次把好几年的数据重查一遍。常见做法是记断点,用时间字段做增量标记。程序把上次成功读取到的最大OPTTIME存到配置或数据库,下次查询从该时间起读,再往后压一个边界,防止漏掉边界秒:
string sql = @" SELECT t.OPTTIME, a.VALUE FROM TIMEBAR t INNER JOIN ARCHIVE a ON t.TIMEBARID = a.TIMEBARID WHERE t.OPTTIME >= @last AND t.OPTTIME < @now ORDER BY t.OPTTIME ASC;";@last取上次断点,@now取本次拉取开始时间,执行完把最大时间回写断点。断点存哪里?简单场景存配置文件,频繁读写用 SQLite 或一张业务表都行。关键在于回写断点一定要在数据成功处理完之后,不能先更新断点再处理数据,否则中途崩了,断点已经跳过去,数据就漏了。
正确性自查是很多人忽略的一步:增量拉完后,统计本次行数和时间范围,和 WINCC 画面里同一时段的数据密度对比。行数能对上,说明断点、分段和类型转换都没问题;对不上,先看是归档本身有缺口,还是查询逻辑漏了条件。我自己的习惯是每次拉完,在导出 CSV 首尾各留一条时间戳,并在日志里记录最小和最大时间,回到工位一眼就能判断这次任务是否正常结束。
最常犯的错是依赖“加了 WHERE 就一定对”。我写时间窗查询时会先在冒烟模式里跑一小段,把结果和 WINCC 趋势截图对比,确认列名和值类型没搞错,再放全量任务。希望这组方法能帮你少踩几个坑。
本文还有配套的精品资源,点击获取