单片机模拟低概率事件:从“第一次就闪”探究随机数生成与系统测试
最近在折腾一个单片机小项目时,遇到了一个特别有意思的现象,让我对“概率”和“系统稳定性”有了新的认识。事情是这样的:我想用单片机模拟一个类似游戏里“刷闪光”(指低概率出现特殊个体)的过程,来测试一个随机路径算法的有效性。结果程序第一次运行,就“闪”了——也就是触发了那个理论上很低概率的事件。这让我既惊喜又困惑:是代码写错了,还是随机数不随机,或者是单纯的运气爆棚?
本文将围绕这个“单片机模拟低概率事件测试”的案例,完整拆解从需求分析、电路搭建、代码编写到结果分析与深度思考的全过程。无论你是刚接触51/STM32单片机的学生,想了解如何用单片机做趣味实验,还是有一定经验的开发者,希望深入理解随机数生成、概率统计在嵌入式系统中的实践,都能从本文中找到可复现的代码和值得探讨的思路。
我们将使用最常见的STC89C52(51内核)单片机作为主控,通过LED、按键和串口来直观展示“刷闪光”的过程和结果。文章不仅会给出完整的原理图、代码,还会深入探讨如何科学地测试低概率事件,以及当测试结果与预期严重不符时,我们应该如何排查和思考。
1. 项目背景与核心概念拆解
在开始动手之前,我们有必要厘清几个关键概念,这能帮助我们更好地理解整个项目的设计意图。
1.1 什么是“刷闪光”? “刷闪光”这个概念源于一些收集类游戏,比如《宝可梦》系列。在游戏中,大部分同类生物外观相同,但有极低概率(例如1/4096)遇到颜色、特效与众不同的“闪光”个体。玩家为了获得它,往往需要反复进行遭遇操作,这个过程被称为“刷闪光”。它本质上是一个 几何分布 的概率问题:每次尝试相互独立,成功概率p极小,需要尝试的次数期望是1/p。
1.2 用单片机模拟的意义何在? 直接用电脑写个Python脚本生成随机数来模拟,不是更简单吗?确实如此。但用单片机模拟,有其独特的实践价值:
- 贴近硬件随机性 :单片机(尤其是没有专用硬件随机数发生器的型号)的随机数种子往往来源于未初始化的内存、ADC读取的噪声等,这些源比电脑系统时间更具“不确定性”,有助于我们理解熵源。
- 学习嵌入式开发流程 :这是一个完整的微型项目,涉及GPIO控制、中断、定时器、串口通信等核心技能。
- 可视化与交互 :可以通过LED、数码管或LCD屏实时显示状态,用按键控制开始/停止,比黑屏命令行更直观有趣。
- 验证理论概率 :通过单片机长时间、自动化运行测试,可以收集大量数据,用于验证大数定律,计算实际概率与理论概率的偏差。
1.3 核心问题:为什么“第一次就闪”? 这是本项目出发点的核心疑问。在概率论中,即使概率只有1/4096,第一次尝试就成功的事件也是可能发生的,其概率就是1/4096。但在工程和测试思维里,我们需要排除其他可能性:
- 随机数发生器(RNG)缺陷 :伪随机数算法有周期或种子设置不当,导致分布不均匀。
- 判断逻辑错误 :代码中判断“闪光”的条件写错了,导致误判。
- 硬件或初始化状态异常 :单片机每次上电都处于一个极度相似的状态,导致生成的第一个随机数总是落入“闪光”区间。
- 纯粹的运气 :这就是概率游戏的魅力所在。
我们的项目,就是要搭建一个平台,去复现、观察并分析这个现象。
2. 硬件环境与软件准备
为了让大家都能动手复现,我们选择最通用、成本最低的硬件方案。
2.1 硬件清单
- 主控芯片 :STC89C52RC(或其他51内核单片机)。它简单易用,资料丰富。
- 开发板或最小系统 :一块包含单片机、晶振(11.0592MHz用于串口)、复位电路、电源模块的板子。
- 显示模块 :
- LED :至少2个。LED1(绿色)用于指示“正在尝试/普通情况”,LED2(红色)用于指示“闪光成功”。
- 可选LCD1602 :用于显示尝试次数、成功次数、实时概率等更多信息。
- 输入模块 :一个轻触按键,用于开始测试。
- 通信模块 :USB转TTL串口模块(如CH340、CP2102),用于将单片机数据发送到电脑串口助手,便于记录和分析。
- 其他 :杜邦线若干,电脑一台。
2.2 软件与驱动
- IDE与编译器 :Keil uVision 5(C51版本)。
- 烧录软件 :STC-ISP(STC官方烧录工具)。
- 串口调试助手 :任意一款,如SSCOM、XCOM、Putty等。
- 驱动程序 :确保USB转TTL模块的驱动已正确安装。
2.3 电路连接示意图
单片机引脚 连接对象
P1.0 绿色LED (阴极接引脚,阳极通过220Ω电阻接VCC)
P1.1 红色LED (接法同上)
P3.2 (INT0) 轻触按键一端(按键另一端接地,引脚内部上拉)
P3.0 (RXD) 接USB转TTL模块的TXD
P3.1 (TXD) 接USB转TTL模块的RXD
VCC 接5V电源
GND 接电源地
注:LED采用低电平点亮方式。使用LCD1602时,需连接数据线和RS、RW、EN控制线。
3. 程序设计:核心逻辑与代码实现
整个程序可以分为几个模块:随机数生成、闪光判断、状态显示、用户交互、数据上报。我们采用模块化的思想编写代码。
3.1 随机数生成算法的选择与陷阱 51单片机没有硬件RNG,我们必须使用伪随机数算法。最常用的是 rand() 和 srand() ,但它们需要 <stdlib.h> 支持,且在51库中可能表现不佳。我们采用一种更简单、经典的 线性同余发生器(LCG) 。
// 文件:random.c
unsigned long seed = 0; // 随机数种子
// 初始化随机数种子,尝试获得一个“随机”的初值
void init_random() {
// 方法1:读取一个未初始化的RAM地址值(有一定随机性)
// unsigned char tmp;
// seed = *((unsigned char xdata *)0x30); // 读取xdata空间某个地址
// 方法2(推荐):结合定时器计数值,增加不确定性
// 假设TIMER0用于计时,我们读取其低字节
// seed = TL0;
// 方法3:固定种子(用于可重复测试)
seed = 12345; // 第一次运行就用这个种子,看是否“第一次就闪”
}
// 生成一个0到RAND_MAX(这里我们定义为65535)之间的伪随机数
unsigned int my_rand() {
seed = seed * 1103515245 + 12345;
return (unsigned int)(seed >> 16) & 0x7FFF; // 返回0-32767之间的数
}
// 生成一个在[min, max]范围内的随机数
int random_range(int min, int max) {
if (min >= max) {
return min;
}
int range = max - min + 1;
return (my_rand() % range) + min;
}
关键点 : init_random() 是核心!如果种子每次上电都相同(比如我们注释掉所有方法, seed 默认为0),那么生成的随机数序列将完全一样。这就是可能导致“第一次就闪”的 根本原因之一 ——不是运气,而是确定的序列。
3.2 “闪光”判断逻辑 假设我们模拟1/4096的概率。我们可以生成一个0到4095之间的随机数,并定义当这个数等于某个特定值(比如0)时,判定为“闪光”。
// 文件:flash_check.c
#define FLASH_PROBABILITY_DENOM 4096 // 概率分母
#define FLASH_TARGET_VALUE 0 // 当随机数等于0时触发闪光
bit check_flash() {
int rnd_num = random_range(0, FLASH_PROBABILITY_DENOM - 1);
if (rnd_num == FLASH_TARGET_VALUE) {
return 1; // 闪光!
} else {
return 0; // 普通
}
}
思考 :这里有没有BUG?如果 random_range 函数分布不均匀,或者 FLASH_PROBABILITY_DENOM 不是2的幂次,而 my_rand() 返回的是0-32767,用取模运算会导致低值区域出现概率略高。这也是需要排查的点。
3.3 主程序流程与状态控制 我们使用一个状态机来管理整个测试流程:等待开始 -> 循环尝试 -> 闪光处理 -> 结果显示。
// 文件:main.c
#include <reg52.h>
#include "random.h"
#include "flash_check.h"
sbit green_led = P1^0;
sbit red_led = P1^1;
sbit start_key = P3^2;
unsigned long total_tries = 0;
unsigned long flash_count = 0;
bit testing = 0;
bit flash_occurred = 0;
void delay_ms(unsigned int ms) {
unsigned int i, j;
for(i=0; i<ms; i++)
for(j=0; j<123; j++); // 粗略延时,实际需校准
}
void uart_init(void) {
// 串口初始化,9600波特率,用于向电脑发送数据
SCON = 0x50;
TMOD |= 0x20;
TH1 = 0xFD;
TL1 = 0xFD;
TR1 = 1;
ES = 0; // 本例不使用中断,采用查询方式
}
void uart_send_byte(unsigned char dat) {
SBUF = dat;
while(TI == 0);
TI = 0;
}
void uart_send_string(char *s) {
while(*s) {
uart_send_byte(*s++);
}
}
void report_status() {
char buf[30];
// 发送尝试次数和成功次数到串口
// 注意:51单片机sprintf可能占用较多资源,这里简化处理
uart_send_string("Tries:");
// ... 此处需将total_tries转换为字符串发送,为简化先省略
uart_send_string(", Flashes:");
// ... 发送flash_count
uart_send_string("\r\n");
}
void main() {
green_led = 1; // 初始化LED熄灭
red_led = 1;
start_key = 1; // 准双向口模式
uart_init();
init_random(); // 初始化随机种子!
uart_send_string("System Ready. Press Key to start flash test.\r\n");
while(1) {
if(start_key == 0) { // 按键按下
delay_ms(20); // 消抖
if(start_key == 0) {
while(!start_key); // 等待按键释放
testing = 1;
flash_occurred = 0;
uart_send_string("--- Test Started ---\r\n");
}
}
if(testing) {
total_tries++;
green_led = 0; // 开始尝试,绿灯亮
delay_ms(50); // 模拟一次尝试的耗时
if(check_flash()) {
flash_count++;
flash_occurred = 1;
red_led = 0; // 闪光,红灯亮
green_led = 1; // 绿灯灭
uart_send_string("*** FLASH! *** at try: ");
// ... 发送total_tries
uart_send_string("\r\n");
testing = 0; // 闪光后停止本轮测试
} else {
green_led = 1; // 尝试结束,绿灯灭
red_led = 1;
// 每100次尝试报告一次状态
if(total_tries % 100 == 0) {
report_status();
}
}
delay_ms(100); // 循环间隔
}
}
}
4. 实验过程、结果与初步分析
按照上述代码搭建好硬件并下载程序后,我们进行测试。
4.1 第一次上电测试(使用固定种子12345)
- 打开串口助手,配置波特率9600。
- 给单片机上电。串口收到
System Ready. Press Key to start flash test.。 - 按下按键。
- 可能的结果A :串口立即打印
*** FLASH! *** at try: 1,红色LED亮起。这就是标题所说的“第一次就闪了”。 - 可能的结果B :绿灯闪烁,串口持续输出尝试次数,一直未出现闪光。
4.2 结果分析
- 如果出现结果A(第一次就闪) :
- 检查种子 :我们的代码中
init_random()使用了固定种子seed = 12345。计算一下:seed=12345->my_rand()第一次调用会返回一个固定值。如果这个值经过random_range(0,4095)计算后恰好等于FLASH_TARGET_VALUE (0),那么必然第一次就闪。 这不是运气,而是确定性事件。 这揭示了测试中的一个重要原则: 伪随机数测试必须关注种子 。 - 验证 :修改
init_random()函数,换一个种子,比如seed = 54321,重新烧录测试。看看是否还是第一次闪。如果换了种子就不闪了,那就证明是种子问题。
- 检查种子 :我们的代码中
- 如果出现结果B(一直不闪) : 这符合1/4096低概率的预期。可以让程序长时间运行(比如尝试几万次),通过串口记录数据,观察闪光出现的频率是否逐渐接近1/4096。
4.3 引入“更随机”的种子 为了模拟更真实的随机环境,我们改进 init_random() 函数:
void init_random() {
// 尝试获取一些“噪声”作为种子
unsigned int noise = 0;
// 方法1:读取定时器0的计数值(需确保定时器已在别处运行)
// noise = (TH0 << 8) | TL0;
// 方法2:读取ADC某个通道的值(如果单片机有ADC)
// noise = read_adc(0);
// 方法3:简单延时,利用循环变量
unsigned int i;
for(i=0; i<50000; i++) {
noise++;
}
seed = noise;
// 再“搅拌”一下
my_rand();
my_rand();
}
使用改进后的种子初始化,再次进行多次上电、按键测试。记录每次“第一次尝试”就闪光的频率。如果这个频率大致是1/4096,那么最初那次“第一次就闪”就可以归因于 小概率事件真的发生了 。
5. 常见问题与深度排查指南
当你的测试结果出乎意料时,可以按照以下清单进行排查:
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 每次上电第一次测试都闪/都不闪 | 随机数种子固定或质量极差。 | 1. 检查 init_random() 函数,确保种子来源有变化。 2. 用串口在每次测试开始前打印出种子值,观察是否不同。 3. 尝试用未初始化的RAM、定时器计数器、ADC噪声组合生成种子。 |
| 闪光概率远高于/低于理论值 | 1. 随机数生成算法有偏差。 2. 判断条件 random_range 函数有误。 3. 概率计算参数设置错误。 |
1. 验证随机数分布 :写一个测试程序,生成大量(如10万个)随机数,统计落在0-4095每个区间的次数,在电脑上画直方图看是否均匀。 2. 检查取模运算 :确保 FLASH_PROBABILITY_DENOM 能整除 my_rand() 的输出范围,或使用更均匀的生成方法(如拒绝采样)。 3. 核对宏定义 :确认 FLASH_PROBABILITY_DENOM 和 FLASH_TARGET_VALUE 的值。 |
| 程序运行一段时间后卡死 | 1. 变量溢出( total_tries 是 unsigned long )。 2. 串口发送函数阻塞(如未处理TI标志)。 3. 看门狗未喂(如果启用)。 |
1. 检查变量范围, unsigned long 最大约42亿,对于长期测试足够。 2. 优化串口发送,确保发送完成标志被正确清除。 3. 如果启用看门狗,在循环内定期喂狗。 |
| 按键控制不灵敏或误触发 | 1. 按键消抖处理不当。 2. 中断与主循环冲突(如果用了中断)。 3. 引脚模式配置错误。 |
1. 增加消抖延时,或使用状态机进行消抖。 2. 避免在中断内进行长时间操作或复杂打印。 3. 确认按键引脚配置为上拉输入或准双向口。 |
| 串口接收数据乱码 | 1. 波特率不匹配。 2. 单片机与电脑共地问题。 3. 晶振频率不标准导致波特率误差大。 |
1. 核对双方波特率,51单片机常用11.0592MHz晶振计算9600波特率。 2. 确保USB转TTL的GND与单片机GND连接。 3. 使用示波器测量实际波特率,或换用误差更小的晶振。 |
6. 最佳实践与工程化思考
通过这个趣味项目,我们可以提炼出一些在嵌入式开发乃至一般软件测试中通用的最佳实践。
6.1 随机数的正确使用
- 理解“伪随机” :单片机生成的几乎都是伪随机数,其序列由种子决定。 种子决定了序列的起点 。
- 寻找熵源 :在嵌入式系统中,可用的熵源包括:ADC读取悬空引脚或热噪声、未初始化的RAM、外部中断间隔时间、RTC的亚秒计数等。将这些值进行异或、移位等操作后作为种子。
- 测试随机性 :对于关键应用(如加密、抽奖),必须对自制的或库提供的随机数函数进行统计测试(如卡方检验),确保其分布均匀且无明显模式。
- 分离种子与状态 :随机数发生器的状态变量(如
seed)应妥善保存,避免被意外修改。
6.2 低概率事件的测试方法
- 自动化与长时间运行 :手动测试低概率事件不现实。必须编写自动化脚本(或让单片机自己循环),进行数万、数十万次测试,才能获得有统计意义的数据。
- 记录完整日志 :像本项目中使用串口输出每一次闪光事件及其发生时的尝试次数、系统状态等。这些日志是分析问题的基础。
- 计算置信区间 :当测试次数n足够大时,观测到的频率p’可以用来估计真实概率p。可以计算p’的置信区间(例如95%置信区间),看理论概率值p是否落在这个区间内。
- 关注“第一次” :对于需要初始化或冷启动的系统,“第一次”的行为往往特殊。应设计专门的用例,反复进行冷启动测试,检查系统状态是否每次都能正确初始化。
6.3 代码与项目规范
- 模块化 :将随机数生成、闪光判断、硬件控制、通信等功能分离到不同
.c/.h文件,提高可读性和可移植性。 - 可配置性 :使用宏定义(如
FLASH_PROBABILITY_DENOM)来管理关键参数,而不是将魔法数字硬编码在逻辑中。 - 添加调试接口 :保留串口打印调试信息的能力。可以定义
DEBUG宏来控制调试信息的输出,在发布版本中关闭以减少代码体积和提高速度。 - 版本控制 :即使是小项目,也使用Git等工具管理代码。为不同的测试(如固定种子测试、随机种子测试)创建不同的分支或标签。
7. 总结与扩展方向
回顾整个项目,我们从“第一次就闪”这个有趣的现象出发,完成了一个完整的单片机应用开发流程:需求分析、硬件搭建、软件编程、测试验证和问题排查。最重要的是,我们认识到 在嵌入式系统中,看似随机的事件背后,往往是确定的代码和硬件状态在起作用 。当测试结果与预期不符时,首先要怀疑的是测试系统本身(种子、算法、逻辑),而不是简单地归因于“运气”。
你可以在此基础上进一步探索:
- 更复杂的概率模型 :模拟不是每次独立,而是有“保底机制”的概率(如尝试次数越多,概率微幅提升)。
- 加入显示设备 :使用LCD1602或OLED实时显示当前尝试次数、成功次数、实时概率和理论概率的对比曲线(需要简单绘图)。
- 多线程/中断模拟 :使用定时器中断来驱动“尝试”循环,主程序只负责显示和按键响应,让程序结构更清晰。
- 与上位机联动 :编写一个简单的Python上位机程序,通过串口接收单片机发送的数据,实时绘制概率收敛图,并进行更复杂的统计分析。
- 更换单片机平台 :尝试在STM32上实现,利用其硬件随机数发生器(RNG)来获得更高质量的随机数,对比测试结果。
通过动手实践和不断追问,我们不仅能解决“为什么第一次就闪”的具体问题,更能建立起一套严谨的嵌入式系统测试与调试方法论。希望这个项目能成为你探索单片机世界的一个有趣起点。
更多推荐



所有评论(0)