资讯中心

STM32与C#上位机串口数据链路:光敏传感器ADC采样与帧解析实战

📅 2026/9/25 16:03:37
STM32与C#上位机串口数据链路:光敏传感器ADC采样与帧解析实战
简介一套面向嵌入式与上位机开发者的完整工程资料围绕STM32与C#串口通信实现光敏传感器数据的读取与终端显示涵盖从单片机采集、协议传输到Windows界面展示的整个链路。压缩包共677个文件、14.07MB其中C源码322个、头文件85个辅以启动与链接脚本和Keil工程文件构成可直接编译的STM32端工程C#端则包含CS源码、XAML界面及Visual Studio解决方案并带有HEX、AXF、EXE等编译产物便于对照源码理解构建过程。该项目已有559人浏览学习适合正在学习HAL库串口编程、SerialPort组件使用或传感器数据采集的初中级开发者。从中可掌握STM32外设初始化、串口收发配置、C#端数据接收解析与实时刷新等关键技能也能借鉴单步调试、断点设置和串口异常处理等排错思路大幅降低环境搭建与联调阶段的时间成本。1. 从光敏电压到 C# 曲线一条串口数据链路的三个环节一块几块钱的光敏传感器模块输出的是随光线变化而改变的模拟电压另一边要把它变成 C# 上位机窗口里一条随时间跳动的亮度曲线中间需要 STM32 的 ADC 完成采样、串口把数据搬到 PC、再由上位机拆帧显示。这个链路不长却覆盖了嵌入式开发中最常见的配合方式HAL 库驱动外设、自定义帧协议、SerialPort 异步事件、跨线程刷新界面。本项目的完整工程STM32-F1.axf、MainWindow.baml 等对应一条可跑通的参考实现本文按采集端、上位机、联调排错的顺序把它拆开讲重点放在帧格式怎么定、粘包半包怎么处理、乱码和数据跳变到底是谁的锅适合正在做课程设计或刚接手传感器上报类项目的读者直接对照着改。2. STM32 采集端HAL 库 ADC 采样与 UART 帧协议设计2.1 光敏模块输出的是分压点电压不是数字量光敏传感器模块的模拟输出端AO 引脚内部结构通常是「光敏电阻 固定电阻」构成的串联分压点。光照增强时光敏电阻阻值下降分压点的电压随之变化这个电压是连续模拟量没法直接通过 GPIO 判断强度必须送入 STM32 的 ADC 引脚做模数转换。STM32F103 的 ADC 是 12 位参考电压取 3.3V 时输入电压 0~3.3V 被映射到 0~4095 的整数值这就是上位机最终要展示的原始数据。要注意的是ADC 输入引脚存在采样电容和通道阻抗。如果传感器模块的输出阻抗偏高采样时间太短会导致转换结果偏小且不稳定。我一般会把采样时间配置到 55.5 周期以上而不是用默认的 1.5 周期这在光线快速变化时会明显减少毛刺。另外STM32F103 的 ADC 输入范围是 0~3.3V官方称通道可容忍 5V 输入但长期超量程采集对通道可靠性没有好处5V 供电的模块最好先确认分压后 AO 引脚电压不超过 3.3V。2.2 CubeMX 配置ADC、UART、定时器三个外设的联动这个场景下CubeMX 里需要同时打开 ADC1、USART1 和一个基本定时器外设之间通过中断和标志位配合。ADC 不需要配置成连续转换模式定时器周期性触发一次单次转换更可控UART 用最普通的阻塞发送即可采集周期 100ms 时一帧 7 个字节的发送耗时远小于一个周期轮询发送完全够用。外设配置项参数值说明ADC1通道IN0对应 PA0接光敏模块 AO 引脚ADC1采样时间55.5 Cycles高输出阻抗场景下的稳定采样USART1波特率115200与 C# 端 SerialPort 保持一致USART1数据位/停止位/校验8 / 1 / None最常见的串口参数组合TIM2触发周期100ms决定数据刷新频率GPIOAPA0 模式Analog必须配置为模拟输入不能是 GPIO_INPUT配置完成后定时器更新事件里置一个adc_ready标志主循环检测到标志就启动一次 ADC 转换并读取结果。用定时器而不是HAL_Delay(100)驱动采集是为了避免主循环里其他逻辑比如按键扫描、LED 闪烁阻塞时采样间隔被拉长导致上位机看到的曲线时间轴不均匀。如果只是验证功能主循环延时也能跑但数据到达率的抖动会比定时器方案明显。2.3 组帧发送从 HAL_ADC_GetValue 到 HAL_UART_TransmitSTM32 端发送的数据不能是裸的 16 位整数值。串口是字节流协议接收端拿到的是连续字节序列如果只发高低两个字节接收方无法判断哪里是边界一旦前一帧丢失一个字节后续所有数据都会错位。常见做法是自定义一个简易帧格式帧头 数据长度 数据类型 数据体 校验和。// main.c 定时器触发后的采集与发送核心逻辑 uint8_t tx_buf[7]; uint16_t adc_val; if (adc_ready_flag) { adc_ready_flag 0; HAL_ADC_Start(hadc1); if (HAL_ADC_PollForConversion(hadc1, 10) HAL_OK) { adc_val HAL_ADC_GetValue(hadc1); tx_buf[0] 0xAA; // 帧头1 tx_buf[1] 0x55; // 帧头2双帧头降低误同步概率 tx_buf[2] 0x01; // 数据长度只统计后面的数据体字节数 tx_buf[3] 0x01; // 数据类型1 表示光敏 ADC 原始值 tx_buf[4] (adc_val 8) 0xFF; // 高字节 tx_buf[5] adc_val 0xFF; // 低字节 tx_buf[6] (tx_buf[0] tx_buf[1] tx_buf[2] tx_buf[3] tx_buf[4] tx_buf[5]) 0xFF; // 校验和除校验本身外所有字节累加取低 8 位 HAL_UART_Transmit(huart1, tx_buf, 7, 100); } }这段代码的逻辑是检测到定时器置位标志后启动一次 ADC 转换并阻塞等待结果拿到 12 位整数值后按大端序拆成两个字节塞进固定格式的帧里发送。校验和覆盖前面 6 个字节接收端重新累加比对不一致就说明链路有误码或丢字节。帧头用0xAA 0x55而不是单个字节是因为光敏数据高低字节本身可能等于0xAA或0x55双帧头能显著降低把数据体误认为帧头的概率。HAL_UART_Transmit最后一个参数是超时时间单位毫秒波特率 115200 时 7 字节发送耗时不到 1ms100ms 超时足够宽松。2.4 为什么建议用 DMA 或中断接收发送却可以用阻塞方式这个项目里 STM32 只负责向上位机主动上报数据没有复杂的下行命令交互所以发送用阻塞式没问题。但如果后续要加入上位机下发配置比如修改采集频率就建议把 UART 接收改成中断方式否则主循环在HAL_UART_Transmit阻塞期间会丢命令帧。需要区分的是「发送阻塞」和「接收阻塞」是两个维度发送阻塞影响的是主循环执行节奏接收阻塞才直接导致数据丢失。这里发送数据量小、周期长阻塞发送的时间占用可以忽略保持代码简单即可。3. C# 上位机SerialPort 接收、帧解析与界面实时刷新3.1 SerialPort 参数必须和 STM32 端逐项对齐C# 端使用System.IO.Ports.SerialPort类配置参数必须与 CubeMX 里完全一致否则会出现能打开串口但数据全是乱码的现象。波特率不一致是乱码最常见的原因其次是数据位和校验位不匹配。注意SerialPort构造函数的第三、第四、第五个参数分别对应校验位、数据位、停止位顺序容易记错。// MainWindow.xaml.cs 中初始化串口 _serialPort new SerialPort(COM3, 115200, Parity.None, 8, StopBits.One); _serialPort.ReadTimeout 500; _serialPort.DataReceived SerialPort_DataReceived; _serialPort.Open();初始化后必须给DataReceived事件挂接处理方法这是 SerialPort 数据到达的通知机制。ReadTimeout只在同步调用Read方法时生效事件驱动模式下基本用不到但设置一个合理值可以避免偶尔的 UI 卡顿排查需求。打开串口前建议先判断IsOpen防止重复打开抛异常程序关闭时在Closed事件里执行_serialPort.Close()和Dispose()否则串口资源会被占用导致下一次启动提示「端口被占用」。3.2 DataReceived 在后台线程触发跨线程更新 UI 必须用 InvokeDataReceived事件由 SerialPort 内部的后台接收线程触发而不是 UI 线程。直接在这个事件里给 TextBox 或 Label 赋值会抛出跨线程访问异常即使用户看到界面「卡住」实际上是异常被吞掉后数据处理流程中断了。正确处理方式是BeginInvoke把更新逻辑调度回 UI 线程执行这样还能天然合并高频 UI 刷新。private void UpdateUI(ushort adcValue) { double voltage adcValue * 3.3 / 4095.0; txtAdcValue.Text adcValue.ToString(); txtVoltage.Text voltage.ToString(F3); txtLux.Text (voltage / 3.3 * 100.0).ToString(F1); }这段代码把 ADC 原始值换算成电压值再映射成一个 0~100 的百分比亮度值。映射公式只是线性近似光敏电阻的阻值与光照强度本身是对数关系但作为课程设计和快速验证完全够用。BeginInvoke是异步调用不会阻塞后台接收线程适合高频数据刷新场景如果要求在更新完 UI 后再处理下一帧就用Invoke同步调用代价是丢帧率上升。高频场景下我更倾向于用BeginInvoke帧率由定时器或渲染逻辑控制而不是由串口接收速度决定。3.3 按帧解析用缓冲队列解决半包和粘包串口是字节流协议应用层必须自己从字节流里切分出完整帧。上位机接收到的数据存在三种情况一帧数据分多次到达半包、多帧数据一次性到达粘包、一帧中间混入脏字节。处理办法是把每次事件收到的字节追加到Listbyte缓冲然后循环从缓冲里尝试解析完整帧解析不出来就等下一批数据。private readonly Listbyte _buffer new Listbyte(); private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e) { int bytesToRead _serialPort.BytesToRead; byte[] data new byte[bytesToRead]; _serialPort.Read(data, 0, bytesToRead); lock (_buffer) { _buffer.AddRange(data); ProcessBuffer(); } } private void ProcessBuffer() { while (_buffer.Count 7) { // 未匹配到帧头就逐个移除直到找到 0xAA 0x55 if (_buffer[0] ! 0xAA || _buffer[1] ! 0x55) { _buffer.RemoveAt(0); continue; } int dataLen _buffer[2]; int totalLen 4 dataLen 1; // 帧头2 长度1 类型1 数据 校验1 if (_buffer.Count totalLen) return; // 半包等下一批数据 byte sum 0; for (int i 0; i totalLen - 1; i) { sum _buffer[i]; } if ((byte)(sum 0xFF) ! _buffer[totalLen - 1]) { _buffer.RemoveAt(0); // 校验失败后丢弃第一个字节继续找帧头 continue; } ushort adcValue (ushort)((_buffer[4] 8) | _buffer[5]); _buffer.RemoveRange(0, totalLen); BeginInvoke(new Action(() UpdateUI(adcValue))); } }ProcessBuffer是这段代码的核心外部数据到达后加入公共缓冲解析循环每次只处理一个完整的帧。帧头不匹配就移除一个字节重新找这是最稳健的做法不会因为错位把整包数据丢掉校验失败也只消耗一个字节而不是放弃所有剩余数据。RemoveRange(0, totalLen)保证已经消费掉的帧从缓冲里删除缓冲区不会无限膨胀。lock (_buffer)保护缓冲列表防止后台接收线程和解析逻辑同时操作同一份数据导致异常。3.4 WPF 界面的布局与刷新策略MainWindow.baml 对应的界面建议做成三分区设计顶部是串口连接控制区串口号下拉框、波特率下拉框、打开/关闭按钮中间是当前值显示区ADC 原始值、电压、亮度百分比三个只读文本框底部是数据日志区带滚动条的只读 TextBox 或 ListBox。日志区要控制追加频率每帧写一行日志在 100ms 周期下会快速堆积到上万行界面会越来越卡。常见做法是只保留最近 200 条日志或者加一个「记录到文件」的开关把原始数据写 CSV 文件用于事后分析界面只显示当前值和最近几条记录。4. 联调实战CH340 驱动、乱码、粘包与数值跳变的排错顺序4.1 先确认串口已经在系统里出现再谈数据STM32 开发板上的 USB 转串口芯片以 CH340 和 CP2102 最为常见第一次接上电脑时设备管理器里不会自动出现 COM 口需要先安装 CH340 串口驱动。驱动装好后在 Windows 设备管理器里展开「端口COM 和 LPT」查看枚举出的 COM 号。插拔开发板时 COM 号会变化所以 C# 端不能硬编码 COM3启动时动态枚举SerialPort.GetPortNames()填入下拉框是基本要求。如果设备管理器里完全没有新端口优先检查 USB 线是不是只供电不传数据的充电线这根线在串口调试失败案例里能占到三成。4.2 三大典型现象对照表现象可能原因排查方向打开串口后收到全乱码波特率不一致或芯片电压域不匹配核对两端波特率检查 STM32 供电电压与 USB 转串口电平是否一致打开串口后完全无数据TX/RX 接反、未共地、模块供电异常交叉 TXD/RXDSTM32 和模块必须共地量 AO 引脚是否有电压有数据但数值频繁跳变采样时间过短、电源纹波、光敏电阻悬空调长 ADC 采样时间AO 引脚就近加 0.1uF 电容到地乱码的第一个排查动作不是改代码是用串口调试助手连上同波特率看是不是也乱码。调试助手乱码就说明是物理层问题上位机改代码没用调试助手正常而 C# 程序乱码才需要检查 SerialPort 参数设置。数据跳变场景下先在 STM32 端把采集到的原始值通过调试助手打印出来如果原始值本身就跳说明问题在采样电路和供电而不是 C# 端解析逻辑的锅。这条排错顺序能省掉大量全栈排查时间。4.3 串口烧写失败的常见诱因用 ST-Link 烧写程序时提示连接失败多数时候不是代码问题。STM32F103 的 BOOT0 引脚状态决定了启动模式BOOT0 拉低是从 Flash 启动烧写时如果它被外部电路拉高芯片会进入系统存储器模式ST-Link 无法正常连接。另外一类高频坑是 CH340 驱动与烧写器共用同一个 USB 口导致的枚举冲突——拔掉 USB 转串口线再烧写往往立刻成功。烧写成功后重新插上串口线C# 端如果提示端口被占用检查是否有串口调试助手之类的程序还在占用 COM 口Windows 下同一个串口在同一时刻只能被一个进程持有。4.4 用虚拟串口在无硬件环境下单独验证 C# 端嵌入式设备和上位机的调试不应该互相等待。在还没有拿到 STM32 板子时C# 端的接收解析逻辑可以先用虚拟串口独立验证。常见做法是安装 VSPD 这类虚拟串口工具创建一对互连的虚拟串口 COM3 和 COM4C# 程序打开 COM3再用随便一个串口调试助手打开 COM4在调试助手里手动发送符合帧格式的十六进制数据比如AA 55 01 01 02 1A 1C看 C# 界面是否正确解析出 ADC 值 0x021A 并计算出电压 0.421V。这一步能把协议解析和 UI 刷新的问题全部暴露在纯软件环境里等硬件就位后只要验证串口参数和电平匹配即可联调时间至少缩短一半。5. 进阶把一帧传感器数据做成可靠且可回放的数据流5.1 从累加和升级到 CRC8累加和只能发现误码发现不了字节顺序调换的问题而且多个字节同时出错时校验值可能巧合一致。数据可靠性要求更高的场景推荐换成 CRC8 查表实现多实例代码在工程中也不会明显增加体积和耗时。private static readonly byte[] Crc8Table new byte[256]; // 初始化查表多项式取 0x07初值 0x00适合短帧数据校验 private static void InitCrc8Table() { for (int i 0; i 256; i) { byte crc (byte)i; for (int j 0; j 8; j) { crc (byte)((crc 0x80) ! 0 ? (crc 1) ^ 0x07 : crc 1); } Crc8Table[i] crc; } } public static byte ComputeCrc8(byte[] data, int offset, int len) { byte crc 0x00; for (int i offset; i offset len; i) { crc Crc8Table[crc ^ data[i]]; } return crc; }CRC8 的检错能力对 7 字节的短帧已经足够计算耗时在微秒级别。替换时注意 STM32 端和 C# 端的多项式、初值、输出异或值必须完全一致否则两端计算出的校验结果对不上。5.2 在采集端先做一次滑动平均光敏传感器数据在光照稳定的室内也会有小幅波动直接显示会看到数值在几十个单位内抖动。最简单的处理是 STM32 端维护一个长度 8 的滑动窗口每次采集后计算平均值再组帧发送。窗口长度要权衡响应速度和平滑度窗口太长会让人手遮挡光敏模块时曲线响应变迟钝8 个点的窗口在 100ms 采集周期下引入 800ms 延迟主观感受还比较自然。C# 端不要再做第二次滤波原始值经过两次滤波后延迟叠加做阈值判断时反而更容易误判。5.3 数据落盘与波形回放数据只在屏幕上滚动显示的话调完就丢没法回看光照变化过程和程序异常的现场。我一般会在 C# 端加一个「记录」开关打开时把时间戳、ADC 原始值、电压值按逗号分隔追加写入 CSV 文件每行一个数据点。CSV 的好处是 Excel 和 Python 都能直接打开后期用 matlab 或 pandas 做数据分析都不用格式转换。写文件用File.AppendAllText逐行追加就够了不用引入缓冲区但要注意文件句柄频繁开关的性能问题。滚动波形显示可以用 WPF 的 Polyline 每隔 N 帧重绘一次或者直接用 ScottPlot 控件的Plot.Add.Scatter数据点数超过 500 个后要裁剪窗口否则重绘开销会拖累 UI 线程。这样整条链路从嵌入式采集到上位机解析、落盘、回放就形成了一个完整闭环项目验收的时候拿出一段 CSV 数据和对应的波形图比口头描述稳定性有说服力得多。本文还有配套的精品资源点击获取

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案