When I worked at Intel as a hardware designer, I used a logic analyzer to debug microprocessors and chipsets. A logic analyzer is a machine that connects to your circuit and converts what's happening in the circuit into a waveform on a screen. It lets you visually inspect how different parts of the circuit behave and interact with each other over time, which can expose the source of bugs. I recently ran into a rather sneaky bug in a circuit I'm building. Since I can't afford the million dollar analyzers I used at Intel, I made a simpler one using my Arduino Uno, Node.js and HTML; it's perfect for typical maker projects.
In this Instructable I'll show you how to turn an Arduino Uno into a four-channel logic analyzer that can sample at about 10~20 microseconds to a depth of 8kb samples for one channel, or 2kb/ch for four. You control the Arduino with a web browser, which in turn uses HTML5, JavaScript, and Node.js to interface to the hardware, and collect and render data: there's no intermediate step where you have to collect and then download the data and feed it to a GUI, it is done automatically.
This project is practically an academic decathlon of programming languages, IDEs, and software packages.
As a good citizen of Instructables, I tested as best I could and avoided hacking trial-and-error solutions. But my validation resources are limited. Hopefully I designed within specifications well enough that you won't encounter any compatibility issues. Fingers crossed.
Here is a list of the software:
As for hardware, I used a Rev1 Arduino UNO.
The stack starts with HTML5 and client-side JavaScript controls to handle all the button clicks and scroll events from the user interface. The JS client communicates with the localhost server running node.js using AJAX POST to a dedicated server endpoint. In addition to serving the user interface, the node.js server manages the serialport object, with it directing communications to the Arduino. The Arduino sketch either polls the serial port for a command string from the server, or samples the DUT on its I/O ports, returning the data on the serial port when sampling finishes. The server reformats the data into a JSON object and sends it to the client via a socket.io push. This complex stack uses a variety of API's, but the code is fairly short (barely a hundred lines for each module). I'll explain each layer in the stack in the following sections.
The index.html file served by the node.js server at http://localhost:8080 displays a control panel. In the panel, a configuration menu appears on the left-hand side, and the screen canvas on the right hand side, with a summary line beneath the configuration. The canvas auto-fits to the screen, and +/- zoom buttons allow you to blow up the waveform and scroll through it. The summary line provides a brief English translation of what will happen when you press Start. The configuration menu is a 1:1 match of the Arduino sketch functionality: each control corresponds to a TTY config command to which the Arduino responds. We'll talk more about the controls later during the Arduino sketch. For now, it is only important to remember that the input controls are converted into a command string and sent to the Arduino when you press the Start button.
The JavaScript located in controls.js communicates in four directions:
Every time you press a control, the status bar updates with a description of what will happen, and if appropriate, a time estimate. Pressing Start causes the JS to compile the input controls into a command string and send it off to the server, and changes the Start button to Wait. (Currently there's no way to stop the execution since the Arduino ignores the serial port while sampling.)
When the sampling finishes, the server sends the data back to the client JS using a socket.io asynch push. The last step in the push handler renders the data to the canvas and switch the Wait button back to Start.
A singleton JavaScript object named viewport manages the canvas and renders the channels' data. During render(), the viewport object scales the data to fit the screen, since the canvas can handle subpixel coordinates nicely. This means if the Arduino collected more samples than the width of the screen, the waveforms may appear as solid blocks. To address this problem, I added zoom buttons to expand the data.
Note:
I originally had coded markers and a time axes, but I removed them because the code was blowing up. I wanted the code to be as small as possible. I'll leave it to the reader to make aesthetic modifications. However, having a time-axis does improve usability when zooming: you won't lose your place as easy. Instead, I added some simple grey rectangles surrounding the channel waveforms, making it easier to identify high and low signals that don't change.
Here are the server's main tasks:
This is all done in about a hundred lines of code thanks to existing npm modules. The server accepts one command-line parameter, the name of the serial port (/dev/tty* on mac/unix, and COM* on Windows).
[Caveat: This is my first time using the serialPort package. I didn't have any problems, but I don't have that feeling of really understanding it yet, which makes me uneasy. Sometimes the debug console has weird characters in it when the Arduino boots. It could be due to the TTY not being flushed, or it could be due to something I don't understand about serial port timing. Please let me know if you have any issues.]
Before server.js starts the HTTP service with the Express app listener, it initializes the serial port provided by the first argument on the command line. There needs to be a three-second timeout waiting for the device to boot. I added additional preventative measures to wait for the "initialized" string from the Arduino before the user can send data.
Client to Server Communication
There are only two endpoints exposed by the server, POST to "/start" and GET from "/".
The Client to Server call via AJAX is handled by a formidable form object. The Express app endpoint "localhost:8080/start" captures the POST transmission via the form object, which comes in as text JSON object natively. I don't really need an object, I could have used a text string, but I left some flexibility. I didn't even register an .on() callback with the POST object, I simply refer to the "fields" parameter during the parse() call.
The index.html page is served using the Express static page server module. Everything in the public/ folder can be served that way, including CSS and JS modules.
Server to Arduino Communication
Since the client already formatted the command string, the server simply writes it to the serial port as-is using serialport.write() via the analyzer object.
Arduino to Server Communication
The response from the Arduino comes in on the serial port's .on('data') callback in the analyzer object
Server to Client Communication
The data callback also handles packaging the data into a JSON object and firing it back to the client via a socket.io push. (This is all very exciting and refreshing compared to doing web push back in the late 1990's!)
I added some log commands so that I can watch the data streams from both the Arduino and the client. I also use the morgan package to trace the HTTP requests.
The Arduino sketch contains a state machine that either listens for serial commands to parse, or launches a data acquisition session. Data acquisition occurs in the form of either edge-triggered sampling or time-based polling. Time based-polling can be trigged by a rising and/or falling edge.
I set up the code to use the following Arduino Uno pins (not to be confused with the ATmega pins):
Command Loop
The serial command loop polls the serial port and waits for characters to arrive. Each command is a string of characters that ends with a '%'. When a '%' is parsed, the current string buffer is shipped off to the router. (The JavaScript client will send one large string with all of the commands.)
Commands:
After receiving the start% command, the sketch configures the global context variables and switches from command parsing to sampling. It cannot be interrupted until it completes and returns data, unless you do a hard reset via button or power.
Time-Based Sampling
If the user did not select any triggering, the sketch determines which time routine it should use. There are three:
The sketch assigns the proper function to a function pointer rather than using if/else conditionals, this speeds execution, and allows better abstraction.
One-Shot Trigger
If the user selected one-shot mode, an interrupt service routine (ISR) is set to INT0 (the trigger), for either RISING, FALLING, or CHANGE. The ISR will then detach the interrupt and invoke time-based sampling as specified above.
Edge-Triggering
Edge-triggered sampling skips the time based routines and collects one sample per trigger. The sketch attaches an ISR to INT0 similar to one-shot mode, but instead of detaching the interrupt and calling the time-based routine, it simply collects one sample per interrupt. As you can tell, if the interrupts arrive faster than the code can process, data will be lost. The limit is around 20us due to the large amount of code in the sample routines.
Data Sampling
All of the sampling invocation methods use the same low-level data acquisition functions.
In attempt to limit the overhead, I wrote four different sampling routines based on the number of channels selected, and call a function pointer instead of if/else. Each routine has its own optimizations. The start% command handler sets the proper function pointer.
The sketch stores the samples in a byte buffer[1080]. This translates to 8640 bit samples for one channel. The number of byte samples must be divisible by 2, 3, and 4. Since I allow 1, 2, 3 or 4 channels to be active, I need to partition the buffer array so that I don't have to perform any special boundary checks. Making the size divisible by 12 is the best way to handle this. I don't enforce it in the code, but I do in the HTML5 interface: notice that the samples control increments by 96 (12 * 8) and stops at 8640 (1080 * 8). As bits are read, they are shifted and packed into each byte. After every 8 bits, a byte is stored at the index and the index is incremented. When the index equals the storage depth, sampling completes and the data is sent. If one channel is selected, data is written to buffer[index]. If two channels are selected, bytes are written to buffer[index] and buffer[index + offset_2]; offset_2 = MAX_BYTE_SAMPLES * 1 / 2 and index is half of what it is in one-channel mode. Three channels write to buffer[index], buffer[index + offset_2] and buffer[index + offset_3]; offset_2 = MAX_BYTE_SAMPLES * 1 / 3 and offset_3 = MAX_BYTE_SAMPLES * 2 / 3, index is 1/3rd of it's max value in one-channel mode, etc.
Once the the buffer fills or the index exceeds the limit%, the sample routines halt and the data is sent back.
Data Send
The data send routine returns the number of samples collected (divided by the # of channels used), and the channels' binary data. Since the user may select any configuration of channels, this routine decodes the channels used and labels them accordingly (a bit of a hassle, actually, I know there's a faster solution out there...). It also brackets the transmission with begindata and enddata to help the server coordinate its parser.
Note
You may notice gratuitous global variables. I did this because I'm trying to get a handle on how to minimize the memory footprint to make room for more sample capacity. One area that needs work: I used a lot of String objects which grow and shrink, and I have a lot of debug char* strings floating around in there. I avoided using malloc() and declared the sample buffer on the heap. I found that 1080 bytes is about the most I can use reliably without crashing the sketch. My to-do list includes understanding memory management better on the ATmega and avr-dude / gcc and re-writing this sketch to be more predictable w.r.t. memory usage.
Download the code from ... [EDIT: Removed]
[Note: I had originally included the code in this Instructable, but decided against it because it would mean the same code would be in two places. Trying to keep code in two places coherent is time consuming without a revision system. Learning how to use github may take some time, but is worth the investment.]
You will need a bunch of node packages. Fortunately npm handles this nicely via the package.json file:
% npm install
This will automatically parse package.json and install the modules into node_modules/.
Plug in your Arduino, upload the sketch, and launch the server from your terminal, example:
% node server.js /dev/tty.usbmodem1411
Where /dev/tty.usbmodem1411 is your Arduino location. If I could test this on Windows, it would be COM1 or something similar.
Now open your browser and go to http://localhost:8080.
You should see the control panel as pictured above.. If not, refer to the terminal to see what error messages. Here is what a successful launch looks like (I chmod my .js servers +x) in the node terminal window:
% ~/proj/Arduinolyzer.js % ./server.js /dev/tty.usbmodem1411 Using device at: /dev/tty.usbmodem1411 initializing serial Arduino... executing 3s delay for port to init... Server ready ArduinoData: initialized GET / 304 4.564 ms - - GET /controls.css 304 2.373 ms - - GET /jquery.js 304 1.513 ms - - GET /socket.io.js 304 1.477 ms - - GET /controls.js 304 1.566 ms - -
To verify the Arduinolyzer is working, I programmed an Arduino Micro to increment a four-bit value at 50ms intervals and write the output to its pins. Using bit one as a trigger I captured the first image shown above. There's no picture of this hardware, but I use it as my test rig to make sure the sampler is working if I'm ever in doubt of the data I'm collecting.
The next two pictures are of a project I'm building. It is a pressure sensitive pad that counts objects moving over it when the pad is pressed. The objects move very, very quickly, passing by in mere milliseconds. Debug LEDs don't work because I can't simply add a software delay loop to the universe (I know, bummer). With the Arduinolyzer, I was able to measure the state of the pad and the objects passing over it.
The first waveform shows the pad sensing pressure, and then the single blips on the three other channels indicate a single object passing overhead. (The pressure sensors are high and low for about 500 milliseconds and the objects pass overhead in about 20ms.) The second waveform shows two objects passing during the sensing intervals. I was able to spot two bugs on the hardware which were due to timing issues in the downstream logic. By adjusting the clocking of downstream flops, I was able to clear up the channels that had time delays. I found these bugs almost instantly after collecting the waveforms, as opposed to weeks of staring at schematics waiting for the problem to jump out at me.
There's not a lot of code in this project, but there are many interfaces, all with their own quirks, and I'm eager to learn where I can bulletproof the stack.
There are definitely some areas I'd like to improve, such as optimizing the sampling routines and memory usage, and porting to faster hardware like a Raspberry Pi or an Edison (I would like to be able to sample sub-microsecond data, and have more channels and greater sample depth). I'd even like to jazz up the user interface to add better timescales, save/restore, markers, and more complex triggering (IF / AND / OR).
I think I can squeeze the server code down even smaller, since it is nothing more than essentially middleware from the client to the hardware.
I recommend that you keep the browser debug console open to catch errors and to follow the status messages. JavaScript console.log() either writes to the browser console (client) or the terminal (server). There are a ton of debug and inspection features built into Chrome and Firefox that I never new existed until about 8 months ago, and they still blow my mind with their intuitive operation (unlike the Microsoft .Net IDE, ugh).
When all else fails, kill the server process, reboot it, reset the Arduino and reload the client page!
I hope you find this useful! Please let me know if you find errors or can't make it work, your input will make it a better Instructable!
Thanks,
Peter