Click here to Skip to main content
15,867,568 members
Articles / Internet of Things / Raspberry-Pi

The Year of IoT - Hooking Up a 2 Line LCD Display

Rate me:
Please Sign up or sign in to vote.
5.00/5 (4 votes)
12 Jan 2019CPOL11 min read 15.2K   7   6
The journey of using .NET Core to send text to a 2 line LCD connected to an rPi

Contents

Introduction

Taking a break from pure software, I am also interested in checking out .NET Core's capabilities to control hardware, in this case using the I2C interface. This led me down a lot of rabbit holes and dead ends, initially finding three posts that provided the necessary solution:

  1. Lesson 30 I2C LCD1602 - C code example
  2. Using .NET Core 2 to read from an I2C device connected to a Raspberry Pi 3 with Ubuntu 16.04
  3. Debian Jessie I2C Communication With C# .Net Core

#2 seems to have been derived from #3, but left out the write P/Invoke declaration. Odd that I couldn't find any examples of using the LCD1602 in C# -- to get this working, it was necessary to port the C code to C# (trivial) and make a couple guesses as to what was going on behind the scenes -- that wiringPiI2CWrite(fd, temp); call in the C code. I've stumbled across "wiringpi" before -- their website seems to be defunct but there are 6 repos in GitHub supporting C, Python, PHP, Ruby, Perl, and Node. But no C#.

It also took a good amount of sleuthing to find the actual datasheet on the display which is really necessary to understand what all the bit writes are doing to control the display -- it's a rather sad state of affairs that of the blog posts on using the LCD1602 with the rPi (or other SBC's like an Arduino), I found a reference to the hardware manufacturer and model, which led to the datasheet PDF, via some comments in C++ code!

Step 0: Obtain an LCD1602 with I2C Piggyback Module

I bought Freenove's Ultimate Starter Kit for Raspberry Pi and was pleasantly surprised when it arrived a few days later. Very packed package of goodies, most of which I haven't explored yet, and a very decent download of PDFs including projects, hardware datasheets, and the link to the GitHub repo. And to my surprise, the LCD1602 had already been assembled with an I2C interface which was great because I had originally intended to use the Grove LCD:

Image 1

because it supported an I2C interface (the white connector) and I had a couple lying around courtesy of a client (I confess I bought Freenove's kit initially just for the hookup wires, hahaha.) The articles I'd seen on the LCD1602 used the GPIO lines and I wasn't keen on creating a wiring mess:

Image 2

(image from Sunfounder Lesson 13 LCD1602)

Instead, the LCD1602 in Freenove's kit has a nice and tidy I2C:

Image 3

(image from Sunfounder I2C LCD1602)

Much neater and faster to hook up!

Step 1: Enable I2C

As in my last article, we have to use sudo raspi-config to enable the I2C. Select "Interface Options":

Image 4

Then select I2C:

Image 5

and select Yes to the prompt:

Image 6

Exit out of the configuration app and reboot your rPi. I'd forgotten that step and so things didn't work too well for me at first!

Step 2: Wire Up the LCD1602

Obviously (I hope), do this with the rPi powered off (the power cable physically disconnected.) You can read about the I2C interface here:

I2C is a serial protocol for two-wire interface to connect low-speed devices like microcontrollers, EEPROMs, A/D and D/A converters, I/O interfaces and other similar peripherals in embedded systems. It was invented by Philips and now it is used by almost all major IC manufacturers.

There is typically one master (the rPi) and an almost unlimited number of slave devices can be attached to the bus (limited I would imagine mainly by address availability -- some devices use multiple addresses.) There are only four wires required for power, ground, clock, and data. Many I2C devices work off of the 3.3V supply (or work in the range from 3.3V to 5V) but in the case of the LCD1602, it needs to be hooked up to the 5V supply. A wiring diagram from here:

Image 7

And my implementation (I used pin 9 for ground):

Image 8

Step 3: Verify Device Can Be Found

Execute this command:

i2cdetect -y -r 1

and you should see:

Image 9

Note the "27" - this indicates that the LCD1602, which by default is at address 0x27, has been detected. If you see only dashes in this position, recheck your wiring.

Step 4: The Code

By merging the pieces from the three articles mentioned in the introduction, I arrived at this code, which has been refactored a bit to handle eventually working with multiple devices (note though that I haven't tested this with multiple I2C devices!), requiring multiple file handles. I'll explain what is in the "..." (enum definitions) in a bit.

C#
using System.Runtime.InteropServices;

namespace consoleApp
{
  public class I2C
  {
    ...

    private static int OPEN_READ_WRITE = 2;

    [DllImport("libc.so.6", EntryPoint = "open")]
    public static extern int Open(string fileName, int mode);

    [DllImport("libc.so.6", EntryPoint = "close")]
    public static extern int Close(int handle);

    [DllImport("libc.so.6", EntryPoint = "ioctl", SetLastError = true)]
    private extern static int Ioctl(int handle, int request, int data);

    [DllImport("libc.so.6", EntryPoint = "read", SetLastError = true)]
    internal static extern int Read(int handle, byte[] data, int length);

    [DllImport("libc.so.6", EntryPoint = "write", SetLastError = true)]
    internal static extern int Write(int handle, byte[] data, int length);

    private int handle = -1;

    public void OpenDevice(string file, int address)
    {
      // From: https://stackoverflow.com/a/41187358
      // The I2C slave address set by the I2C_SLAVE ioctl() is stored in an i2c_client
      // that is allocated everytime /dev/i2c-X is opened. So this information is local 
      // to each "opening" of /dev/i2c-X.
      handle = Open("/dev/i2c-1", OPEN_READ_WRITE);
      var deviceReturnCode = Ioctl(handle, (int)IOCTL_COMMAND.I2C_SLAVE, address);
  }

    public void CloseDevice()
    {
      Close(handle);
      handle = -1;
    }

    protected void WriteByte(byte data)
    {
      byte[] bdata = new byte[] { data };
      Write(handle, bdata, bdata.Length);
    }
  }
}

And the class that initializes and writes to the LCD1602:

C#
using System.Text;
using System.Threading;

namespace consoleApp
{
  public class Lcd1602 : I2C
  {
    ... (more enums, explained below)

    protected void SendCommand(int comm)
    {
      byte buf;
      // Send bit7-4 firstly
      buf = (byte)(comm & 0xF0);
      buf |= 0x04; // RS = 0, RW = 0, EN = 1
      buf |= 0x08;
      WriteByte(buf);
      Thread.Sleep(2);
      buf &= 0xFB; // Make EN = 0
      buf |= 0x08;
      WriteByte(buf);

      // Send bit3-0 secondly
      buf = (byte)((comm & 0x0F) << 4);
      buf |= 0x04; // RS = 0, RW = 0, EN = 1
      WriteByte(buf);
      Thread.Sleep(2);
      buf &= 0xFB; // Make EN = 0
      WriteByte(buf);
    }

    protected void SendData(int data)
    {
      byte buf;
      // Send bit7-4 firstly
      buf = (byte)(data & 0xF0);
      buf |= 0x05; // RS = 1, RW = 0, EN = 1
      buf |= 0x08;
      WriteByte(buf);
      Thread.Sleep(2);
      buf &= 0xFB; // Make EN = 0
      buf |= 0x08;
      WriteByte(buf);

      // Send bit3-0 secondly
      buf = (byte)((data & 0x0F) << 4);
      buf |= 0x05; // RS = 1, RW = 0, EN = 1
      buf |= 0x08;
      WriteByte(buf);
      Thread.Sleep(2);
      buf &= 0xFB; // Make EN = 0
      buf |= 0x08;
      WriteByte(buf);
    }

    public void Init()
    {
      SendCommand(0x33); // Must initialize to 8-line mode at first
      Thread.Sleep(2);
      SendCommand(0x32); // Then initialize to 4-line mode
      Thread.Sleep(2);
      SendCommand(0x28); // 2 Lines & 5*7 dots
      Thread.Sleep(2);
      SendCommand(0x0C); // Enable display without cursor
      Thread.Sleep(2);
      SendCommand(0x01); // Clear Screen
    }

    public void Clear()
    {
      SendCommand(0x01); //clear Screen
    }

    public void Write(int x, int y, string str)
    {
      // Move cursor
      int addr = 0x80 + 0x40 * y + x;
      SendCommand(addr);

      byte[] charData = Encoding.ASCII.GetBytes(str);

      foreach (byte b in charData)
      {
        SendData(b);
      }
    }
  }
}

To use this code to write a message to the LCD1602, we can now call this test method:

C++
static void Main(string[] args)
{
  Console.WriteLine("Testing 1602");
  Test1602();
}

static void Test1602()
{
  Lcd1602 lcd = new Lcd1602();
  lcd.OpenDevice("/dev/i2c-1", LCD1602_ADDRESS);
  lcd.Init();
  lcd.Clear();
  lcd.Write(0, 0, "Hello");
  lcd.Write(0, 1, "     World!");
  lcd.CloseDevice();
}

After publishing and SCP'ing to the rPi, when we run consoleApp, it displays:

Image 10

What's Really Going On?

The above code is all fine and good, but what is really going on, and in particular, what are all these magic bits that are being or'ed and and'ed in the LCD1602 class?

I2C Basics

After some digging, I found this repo which contains, among many other files:

This described what the magic 0x07... numbers are, which I've recoded in enums, preserving the comments from the .h file:

C#
// From: https://github.com/spotify/linux/blob/master/include/linux/i2c-dev.h
private enum IOCTL_COMMAND
{
  /* /dev/i2c-X ioctl commands. The ioctl's parameter is always an
  * unsigned long, except for:
  * - I2C_FUNCS, takes pointer to an unsigned long
  * - I2C_RDWR, takes pointer to struct i2c_rdwr_ioctl_data
  * - I2C_SMBUS, takes pointer to struct i2c_smbus_ioctl_data
  */

  // number of times a device address should be polled when not acknowledging 
  I2C_RETRIES = 0x0701,

  // set timeout in units of 10 ms
  I2C_TIMEOUT = 0x0702,

  // Use this slave address 
  I2C_SLAVE = 0x0703,

  // 0 for 7 bit addrs, != 0 for 10 bit 
  I2C_TENBIT = 0x0704,

  // Get the adapter functionality mask
  I2C_FUNCS = 0x0705,

  // Use this slave address, even if it is already in use by a driver!
  I2C_SLAVE_FORCE = 0x0706,

  // Combined R/W transfer (one STOP only) 
  I2C_RDWR = 0x0707,

  // != 0 to use PEC with SMBus 
  I2C_PEC = 0x0708,

  // SMBus transfer 
  I2C_SMBUS = 0x0720, 
}

Not that I actually understand what all these options are and do, but at least there is some explanation of what they mean. Also, note that the .h file will probably be useful in the future for I2C_SMSBUS and I2C_RDWR control functions, which I show here in their C form:

C++
/* This is the structure as used in the I2C_SMBUS ioctl call */
struct i2c_smbus_ioctl_data {
  __u8 read_write;
  __u8 command;
  __u32 size;
  union i2c_smbus_data __user *data;
};

/* This is the structure as used in the I2C_RDWR ioctl call */
  struct i2c_rdwr_ioctl_data {
  struct i2c_msg __user *msgs; /* pointers to i2c_msgs */
  __u32 nmsgs; /* number of i2c_msgs */
};

Writing to the LCD1602 I2C

To understand in more detail what all these magic bits are doing, I ended up reviewing the C++ code in the Arduino Liquid Crystal I2C Library, particularly:

The .h file in particular defines a variety of bit constants which I've recoded as C# enums:

C#
// Source for enums:
// https://github.com/fdebrabander/Arduino-LiquidCrystal-I2C-library/blob/master/LiquidCrystal_I2C.h
// commands
private enum Commands
{
  LCD_CLEARDISPLAY = 0x01,
  LCD_RETURNHOME = 0x02,
  LCD_ENTRYMODESET = 0x04,
  LCD_DISPLAYCONTROL = 0x08,
  LCD_CURSORSHIFT = 0x10,
  LCD_FUNCTIONSET = 0x20,
  LCD_SETCGRAMADDR = 0x40,
  LCD_SETDDRAMADDR = 0x80,
}

// flags for display entry mode
private enum DisplayEntryMode
{
  LCD_ENTRYRIGHT = 0x00,
  LCD_ENTRYLEFT = 0x02,
  LCD_ENTRYSHIFTINCREMENT = 0x01,
  LCD_ENTRYSHIFTDECREMENT = 0x00,
}

// flags for display on/off control
private enum DisplayControl
{
  LCD_DISPLAYON = 0x04,
  LCD_DISPLAYOFF = 0x00,
  LCD_CURSORON = 0x02,
  LCD_CURSOROFF = 0x00,
  LCD_BLINKON = 0x01,
  LCD_BLINKOFF = 0x00,
}

// flags for display/cursor shift
private enum DisplayCursorShift
{
  LCD_DISPLAYMOVE = 0x08,
  LCD_CURSORMOVE = 0x00,
  LCD_MOVERIGHT = 0x04,
  LCD_MOVELEFT = 0x00,
}

// flags for function set
private enum FunctionSet
{
  LCD_8BITMODE = 0x10,
  LCD_4BITMODE = 0x00,
  LCD_2LINE = 0x08,
  LCD_1LINE = 0x00,
  LCD_5x10DOTS = 0x04,
  LCD_5x8DOTS = 0x00,
}

// flags for backlight control
private enum BacklightControl
{
  LCD_BACKLIGHT = 0x08,
  LCD_NOBACKLIGHT = 0x00,
}

private enum ControlBits
{
  En = 0x04, // Enable bit
  Rw = 0x02, // Read/Write bit
  Rs = 0x01 // Register select bit
}

In the .cpp file, I found this gem:

C++
// put the LCD into 4 bit mode
// this is according to the hitachi HD44780 datasheet
// figure 24, pg 46

Odd to see a code block that has just comments, isn't it. However, it pointed me to the Hitachi HD44780 datasheet. Google that, here's a link to one site of many having a downloadable PDF. Finally! The real "source" for what is going on. For example, looking at the C++ code that initializes the display:

C++
// we start in 8bit mode, try to set 4 bit mode
write4bits(0x03 << 4);
delayMicroseconds(4500); // wait min 4.1ms

// second try
write4bits(0x03 << 4);
delayMicroseconds(4500); // wait min 4.1ms

// third go!
write4bits(0x03 << 4);
delayMicroseconds(150);

// finally, set to 4-bit interface
write4bits(0x02 << 4);

This corresponds to the C# code that I'd found:

C#
SendCommand(0x33); // Must initialize to 8-line mode at first
Thread.Sleep(2);
SendCommand(0x32); // Then initialize to 4-line mode

The difference here is that each nybble is being sent, hence writing 0x33 and 0x32 corresponds to the C++ code that writes 0x03, 0x03, 0x03, and 0x02.

This corresponds to the documentation in datasheet that describes the initialization workflow:

Image 11

But it still doesn't explain these magic bits and why 2 writes occur for each 4 bits, for example, this fragment which writes the 4 most significant (MSB) bits of command:

C#
// Send bit7-4 firstly
buf = (byte)(comm & 0xF0);
buf |= 0x04; // RS = 0, RW = 0, EN = 1
buf |= 0x08;
WriteByte(buf);
Thread.Sleep(2);
buf &= 0xFB; // Make EN = 0
buf |= 0x08;
WriteByte(buf);

I also want to understand why the device is put into 4 bit mode as opposed to leaving it in 8 bit mode.

In the datasheet, page 22, we read: "The data transfer between the HD44780U and the MPU is completed after the 4-bit data has been transferred twice." Let's look at the write functions in the C++ code:

C++
void LiquidCrystal_I2C::write4bits(uint8_t value) {
  expanderWrite(value);
  pulseEnable(value);
}

void LiquidCrystal_I2C::expanderWrite(uint8_t _data){
  Wire.beginTransmission(_addr);
  Wire.write((int)(_data) | _backlightval);
  Wire.endTransmission();
}

void LiquidCrystal_I2C::pulseEnable(uint8_t _data){
  expanderWrite(_data | En); // En high
  delayMicroseconds(1); // enable pulse must be >450ns

  expanderWrite(_data & ~En); // En low
  delayMicroseconds(50); // commands need > 37us to settle
}

This code is particularly odd because the write4bits is calling expanderWrite 3 times! I wonder if that's a bug in the code.

So we see in this code that, as per the datasheet, each nybble is being sent twice, once with the Enable high, the second time with the Enable low. This is controlled by bit 2 (we always count from 0): En = 0x04, // Enable bit. Furthermore, the display is always set to "on" when writing a command, controlled by bit 3: LCD_BACKLIGHT = 0x08. If we don't do this when writing a "command", the display "flashes" (it turns dark) when writing text to the display because first the command has to be sent (if bit 3 is not set, the display goes dark), then the text, which sets bit 3 and turns on the display.

Unfortunately, none of this "command/data in the upper four bits, and display + enable + read/write + register select in the lower bits", is described in the datasheet! After some serious digging, I came across this link that stated: "There are a couple ways to use I2C to connect an LCD to the Raspberry Pi. The simplest is to get an LCD with an I2C backpack. The hardcore DIY way is to use a standard HD44780 LCD and connect it to the Pi via a chip called the PCF8574." Ah ha! We can now look at the datasheet for the PCF8574! Furthermore, it finally dawns on me that the reason the LCD1602 is put into 4 bit mode is because 4 bits have to be used as the data and 3 bits need to be used as control of the LCD1602's enable (E), register select (RS) and read/write (R/~W) lines, as shown here (page 3 of the datasheet for the LCD1602):

Image 12

That explains bits 0, 1 and 2:

C#
private enum ControlBits
{
  En = 0x04, // Enable bit
  Rw = 0x02, // Read/Write bit
  Rs = 0x01 // Register select bit
}

but two questions remain:

  1. Where does it say in the LCD1602 datasheet that the enable line has to be toggled for each nybble: "...after the 4-bit data has been transferred twice".
  2. What is bit 3 (0x08) doing when we send a command or data? This is clearly controlling the display on/off!

Page 21:

"For 4-bit interface data, only four bus lines (DB4 to DB7) are used for transfer. Bus lines DB0 to DB3 are disabled. The data transfer between the HD44780U and the MPU is completed after the 4-bit data has been transferred twice."

The Mystery of Bit 3 (0x08) Solved

The second question first. I've been setting this bit because of this C code from Lesson 30 I2C LCD1602 - C code example:

C++
int LCDAddr = 0x27;
int BLEN = 1;
int fd;

void write_word(int data){
  int temp = data;
  if ( BLEN == 1 )
    temp |= 0x08;
  else
    temp &= 0xF7;
  wiringPiI2CWrite(fd, temp);
}

What is BLEN and why is it set to 1? And finally, I find what I'm looking for here: int BLEN = 0;//1--open backlight.0--close backlight with the following hardware connection diagram (I added the red lines):

Image 13

Note that P3 on the PCF8574 is wired to the base of transistor Q1 which controls K, which must control the backlight voltage! If P3 is 1, K grounds. This is verified from yet another Google find:

Image 14

and the associated text:

15. BLA - Backlight Anode (+)
16. BLK - Backlight Cathode (-)

The last 2 pins (15 & 16) are optional and are only used if the display has a backlight.

If the backlight isn't grounded, then it isn't illuminated. Crazy. Furthermore, we also see how the first 3 LSBs if the 8 bit data byte are mapped to the enable, register select, and R/~W lines:

Image 15

The Mystery of Toggling P2 When Writing in 4-bit Mode Solved

P2 (0x04) called "CS" (chip select) is the "Enable" pin 6 on the LCD1602. Going back to the LCD1602 datasheet, we see that the enable (E) pin starts the read/write operation:

Image 16

Furthermore, let's look at this timing diagram from the LCD1602 datasheet, page 22:

Image 17

Notice that the enable line must be toggled between the two nybbles. The only way to do this is to write "something" (anything should do) with bit 2 set to 0 to bring the line low. So now we can understand why the each nybble has to be written twice. Understanding this, all the code examples I've come across can be simplified to this:

C#
byte buf;
// Send bit7-4 firstly
buf = (byte)(comm & 0xF0);
buf |= 0x04; // RS = 0, RW = 0, EN = 1
buf |= 0x08;
WriteByte(buf);
Thread.Sleep(2);
WriteByte(8);   // <<< === Set EN = 0, keep the display on, we don't care what the data is.

// Send bit3-0 secondly
buf = (byte)((comm & 0x0F) << 4);
buf |= 0x04; // RS = 0, RW = 0, EN = 1
buf |= 0x08;
WriteByte(buf);
Thread.Sleep(2);
WriteByte(8);	// <<< === Set EN = 0, keep the display on, we don't care what the data is.

Finally! (I'm saying that a lot. It's taken at least 8 hours of Googling to find the various links I've referenced to put all the pieces together!)

Now to clean the code up with the various enum constants:

C#
byte buf;
// Send bit7-4 firstly
buf = (byte)(comm & 0xF0);
buf |= (byte)ControlBits.En | (byte)BacklightControl.LCD_BACKLIGHT;
WriteByte(buf);
Thread.Sleep(2);
WriteByte((byte)BacklightControl.LCD_BACKLIGHT);

// Send bit3-0 secondly
buf = (byte)((comm & 0x0F) << 4);
buf |= (byte)ControlBits.En | (byte)BacklightControl.LCD_BACKLIGHT;
WriteByte(buf);
Thread.Sleep(2);
WriteByte((byte)BacklightControl.LCD_BACKLIGHT);

Commands and Data

Using this key (page 23 of the LCD1602 datasheet):

C#
Function set:
DL = 1; 8-bit interface data
N = 0; 1-line display
F = 0; 5 � 8 dot character font

Display on/off control:
D = 0; Display off
C = 0; Cursor off
B = 0; Blinking off

Entry mode set:
I/D = 1; Increment by 1
S = 0; No shift

we can now understand the commands we can send to the LCD1602 (table 6, page 24):

Image 18

So, for example, writing to the display (the DDRAM) has the RS line low and bit 7 (0x80) is always high:

Image 19

where the first line starts at address 0 and the second line starts at address 0x40:

Image 20

which corresponds to the code int addr = 0x80 + 0x40 * y + x;

Other commands are determined similarly by reviewing table 6 (image above.) And yes, at this point, I'm done with this stuff, so I leave it to the reader to have enough understanding to explore all the commands. And I am not going to figure out how to program the character set!

Conclusion

The odd thing about this article is that getting the code to work took a couple hours of digging around and putting the pieces together. Understanding how the code works took 4 times as long. But it was well worth the journey as I now have a solid understanding of how to use the I2C interface and read hardware diagrams for future devices.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Architect Interacx
United States United States
Blog: https://marcclifton.wordpress.com/
Home Page: http://www.marcclifton.com
Research: http://www.higherorderprogramming.com/
GitHub: https://github.com/cliftonm

All my life I have been passionate about architecture / software design, as this is the cornerstone to a maintainable and extensible application. As such, I have enjoyed exploring some crazy ideas and discovering that they are not so crazy after all. I also love writing about my ideas and seeing the community response. As a consultant, I've enjoyed working in a wide range of industries such as aerospace, boatyard management, remote sensing, emergency services / data management, and casino operations. I've done a variety of pro-bono work non-profit organizations related to nature conservancy, drug recovery and women's health.

Comments and Discussions

 
QuestionImages cannot be displayed Pin
_Nizar12-Jan-19 21:51
_Nizar12-Jan-19 21:51 
AnswerRe: Images cannot be displayed Pin
Marc Clifton13-Jan-19 3:41
mvaMarc Clifton13-Jan-19 3:41 
QuestionNice Pin
Mike Hankey12-Jan-19 12:12
mveMike Hankey12-Jan-19 12:12 
AnswerRe: Nice Pin
Marc Clifton13-Jan-19 3:42
mvaMarc Clifton13-Jan-19 3:42 
Mike Hankey wrote:
The embedded field is a very addictive hobby, good thing my GF sews a lot or I'd be doing a whole lot less.


Indeed it is. My wife spent the last month studying very hard for her NY state license as a LMHT so I had a lot of extra time, haha. She took the test yesterday and passed! Awesome! Only 50% of people pass it on their first try.
Latest Article - A Concise Overview of Threads

Learning to code with python is like learning to swim with those little arm floaties. It gives you undeserved confidence and will eventually drown you. - DangerBunny

Artificial intelligence is the only remedy for natural stupidity. - CDP1802

GeneralRe: Nice Pin
Mike Hankey13-Jan-19 4:25
mveMike Hankey13-Jan-19 4:25 
GeneralRe: Nice Pin
Marc Clifton13-Jan-19 5:45
mvaMarc Clifton13-Jan-19 5:45 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.